diff --git a/README.md b/README.md index 494194f..a3a8156 100644 --- a/README.md +++ b/README.md @@ -2,55 +2,111 @@ ## Overview -We'd like you to implement a modal dialog component using the codebase provided. We've kept the brief intentionally -loose in places - we're interested in the decisions you make, not just the end result. +Implement a modal dialog component using the codebase provided. ## The task Using the existing codebase as your starting point, implement a modal dialog component. A rough reference for behaviour and appearance can be found here: [stripe.com](https://stripe.com/au) (see expand icon on homepage cards). -Consider this a starting point, not a strict spec. Your implementation should fit the patterns and conventions already -established in the codebase. +Implementation should fit the patterns and conventions already established in the codebase. -## Requirements - -The dialog should: +## What to submit -- Open and close correctly -- Avoid using any external UI libraries - we'd like to see your own implementation -- Be keyboard accessible - including Escape to close, and correct focus management when opening and closing -- Follow the existing component patterns, naming conventions, and file structure in the codebase -- Be written in TypeScript +- [x] See the [CONTRIBUTING.md](./CONTRIBUTING.md) file for setup instructions and guidelines. + +## Process + +- [x] Read through task 10:30 and plan +- [x] Onboard & check + - [x] pnpm audit - (PMG) notes critical npm packages: look at --fix +- [x] Plan 30m and Discovery + - [x] need to install oxc.oxc-vscode formatter to match code styles. + +- [x] Run tests on current code. + - [x] `pnpm test` - worked - [x] `pnpm lint` - Found 40 errors in 4 files. + +``` + Errors Files + 10 src/elements.ts:3 + 1 src/Utility/Elements/breakpoint-loader.ts:75 + 1 src/Utility/Elements/io-loader.ts:58 + 28 src/Utility/Elements/keyboard.ts:7 +``` + +- [x] `pnpm test` + +``` + Snapshots 1 failed + Test Files 11 failed | 28 passed (39) + Tests 57 failed | 54 passed (111) +``` + +Deciding to move on... + +- find components and read through current component/theme implementations +- [x] Stripe example Components used + - [x] Buttons with arrow icon and links + - [x] Expand / Close icons + - [x] Grids: 2/3, 1/3, full + - [x] Cards and backgrounds + - [x] Text, quotes, logo, headings + +- [x] Ideation 30m - decide on way forward + - [x] Can I use dialog element? + - [x] But if the implementation should be written in TypeScript - then change to div and custom open/close. + +- [x] Iterate + - [x] Test assumptions + - [x] Create storybook component + - [x] Style a Story. + - [x] Check Accessibility + - [x] Add modifiers for Dialog storybook component + - [] Make a Story with multiple dialogs to test open / close of each on a page. + - [] Style dialogs on a page and ::backdrop. + - [ ] update snapshots & Write interaction tests + - [x] docs + - [x] commits + +## Requirement checklist -## What we're not prescribing +The dialog should: -We've deliberately left the following open - please make your own decisions and note them down: +- [x] Open and close correctly +- [x] Avoid using any external UI libraries - we'd like to see your own implementation +- [x] Be keyboard accessible - including Escape to close, +- [ ] And correct focus management when opening and closing +- [1/2] Follow the existing component patterns, naming conventions, and file structure in the codebase +- [x] Be written in TypeScript << GOT STUCK HERE on the open / closer functions >> -- Mobile behaviour and breakpoints -- What happens to page scroll when the dialog is open -- Animation and transition behaviour -- How the trigger element is handled +- [] Mobile behaviour and breakpoints +- [x] I'd want to fix main background, so scroll only affects the content of the dialog when open +- [] Animation and transition behaviour +- [x] How the trigger element is handled -## What to submit +- [x] Run tests, linters +- [x] Docs +- [x] Commit -Along with your code, please include a brief README (a few bullet points is fine) covering: +## Submission - Any assumptions you made where the spec was unclear -- Any trade-offs or decisions you'd approach differently with more time -- Anything you noticed in the existing codebase you'd flag in a code review - -## Time + - [x] whether to use dialog element + - https://caniuse.com/?search=dialog + - https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog > Global 96.86% -We'd suggest around 2–3 hours, but there's no hard limit. We're more interested in quality and thoughtfulness than -completeness - if you run out of time, notes on what you'd do next are just as valuable. + - [] or latest popover with polyfills for browsers not yet compatible. + - [x] But suggestion is to use TypeScript, so will do a more manual custom dialog -## Follow-up +- [x] Any trade-offs or decisions you'd approach differently with more time + - Get more familiar with the available utility styles and colours and spacing css vars. + - Read the Function docs! + - Investigate and Fix the other Failed tests -We'll schedule a short call to walk through your submission together. Be prepared to talk through your decisions - there -are no trick questions, we just want to understand your thinking. +- [x] Anything you noticed in the existing codebase you'd flag in a code review + - The format/linter changes single quotes back to double quotes (but "semi": false, inoxfmtrc.json) + - NPM packages security audit - 1 critical package -## Ready to start? +- [x] include a README (this): -Great! Please see the [CONTRIBUTING.md](./CONTRIBUTING.md) file for setup instructions and guidelines on how to submit -your work. We look forward to seeing your implementation! +https://github.com/stewest/frontend-challenge diff --git a/package.json b/package.json index b4ec425..b20a1cd 100644 --- a/package.json +++ b/package.json @@ -78,5 +78,5 @@ "engines": { "node": ">= 22.0" }, - "packageManager": "pnpm@10.32.1" + "packageManager": "pnpm@10.33.0" } diff --git a/src/Component/Dialog/Dialog.stories.ts b/src/Component/Dialog/Dialog.stories.ts new file mode 100644 index 0000000..00b4672 --- /dev/null +++ b/src/Component/Dialog/Dialog.stories.ts @@ -0,0 +1,93 @@ +import { Meta, StoryObj } from "@storybook/html-vite" +import Component from "./dialog.twig" +import Heading from "../../Atom/Heading/heading.twig" +import "./Elements/Dialog" +import "./dialog.css" +import "../Card/card.css" +import { Heading as HeadingType, HeadingTypes, WysiwygText } from "@pnx-mixtape/ids-shape" + +export type Dialog = { + title?: HeadingType + content: WysiwygText + dialogTitle: HeadingType + dialogContent: WysiwygText + state?: boolean + id?: string + toggleText?: string +} + +const meta: Meta = { + tags: ["autodocs", "ids-mvp"], + component: Component, + args: { + title: Heading({ + title: "Closed state 'Dialog card' element title", + as: HeadingTypes.TWO, + }), + dialogTitle: Heading({ + title: "This is the open Custom 'Dialog' Element title", + as: HeadingTypes.TWO, + }), + content: + "

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

", + dialogContent: + "

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

", + state: false, + }, + argTypes: { + title: { + description: + "Optional [Heading](/?path=/docs/atom-heading--docs) component, displayed above the Dialog.", + control: "text", + }, + content: { + description: "Content.", + control: "text", + type: { + name: "string", + }, + }, + dialogTitle: { + description: + "Optional [Heading](/?path=/docs/atom-heading--docs) component, displayed above the Dialog.", + control: "text", + }, + dialogContent: { + description: "Content.", + control: "text", + type: { + name: "string", + }, + }, + state: { + description: "Dialog open or closed", + control: "boolean", + table: { + defaultValue: { summary: "closed" }, + }, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Dialog: Story = { + args: { + content: + "

This is the default story content text inside the dialog card part 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

", + dialogContent: + "

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Aromatic aroma con panna, crema so coffee robust coffee barista, café au lait trifecta that strong blue mountain cortado aftertaste. Aroma extraction french press, skinny sweet, blue mountain cup roast barista, beans, extra cappuccino mug crema strong. Americano caffeine white, con panna saucer sit, con panna eu, carajillo aftertaste kopi-luwak, body aftertaste cup single origin café au lait saucer

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

", + }, +} + +/** + * Open by default dialog. + */ +export const StateOpen: Story = { + args: { + ...meta.args, + state: true, + id: "open-dialog", + }, +} diff --git a/src/Component/Dialog/Elements/Dialog.ts b/src/Component/Dialog/Elements/Dialog.ts new file mode 100644 index 0000000..c396948 --- /dev/null +++ b/src/Component/Dialog/Elements/Dialog.ts @@ -0,0 +1,97 @@ +/** + * Dialog + * @file Support opening on hash, adding an ID attribute and toggling on print. + */ + +import { makeAnchor } from "../../../Utility/utilities" + +export default class Dialog extends HTMLElement { + internals_: ElementInternals + controller: AbortController + + constructor() { + super() + this.internals_ = this.attachInternals() + this.controller = new AbortController() + } + + connectedCallback(): void { + if (!this.dialogElement || !this.openTrigger || !this.closerTigger) return + + const { signal }: AbortController = this.controller + + document.addEventListener("click", this.handleClick, { + signal, + }) + + document.addEventListener("keydown", event => { + if (event.code === "Escape") { + this.handleClose() + } + }) + } + + disconnectedCallback(): void { + this.controller.abort() + } + + handleClick = ({ target }) => { + if (target === this.openTrigger) { + this.handleOpen() + } + if (target === this.closerTigger) { + this.handleClose() + } + } + + handleOpen = (): void => { + this.dialogElement.setAttribute("data-state", "open") + } + + handleClose = (): void => { + this.dialogElement.setAttribute("data-state", "closed") + } + + get dialogElement(): HTMLElement | null { + const dialogElement: HTMLElement | null = this.querySelector(".mx-dialog__element") + + if (!dialogElement) { + throw new Error(`${this.localName} must contain an element with .mx-dialog__element class.`) + } + dialogElement.id = dialogElement.id || this.generatedId() + return dialogElement + } + + get openTrigger(): HTMLElement | null { + const trigger: HTMLElement | null = this.querySelector(".mx-dialog__toggle button") + + if (!trigger) { + throw new Error(`${this.localName} must contain an element with .mx-dialog__toggle>.`) + } + return trigger + } + + get closerTigger(): HTMLElement | null { + const trigger: HTMLElement | null = this.querySelector(".mx-dialog__element__close button") + + if (!trigger) { + throw new Error( + `${this.localName} must contain an element with class mx-dialog__element__close>.`, + ) + } + return trigger + } + + generatedId = (): string => { + const string: string | undefined = this.openTrigger?.textContent?.trim() + return !string ? "" : makeAnchor(string) + } +} + +if (!customElements.get("mx-dialog")) customElements.define("mx-dialog", Dialog) + +declare global { + interface HTMLElementTagNameMap { + "mx-dialog": Dialog + } +} diff --git a/src/Component/Dialog/__snapshots__/Dialog.stories.ts.snap b/src/Component/Dialog/__snapshots__/Dialog.stories.ts.snap new file mode 100644 index 0000000..42ffbcb --- /dev/null +++ b/src/Component/Dialog/__snapshots__/Dialog.stories.ts.snap @@ -0,0 +1,69 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Dialog 1`] = ` +" + + +
+
+

+

Closed state 'Dialog card' element title

+ + +
+

This is the default story content text inside the dialog card part 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+
+
+ + +
+
+
+ +

This is the open Custom 'Dialog' Element title

+ + +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Aromatic aroma con panna, crema so coffee robust coffee barista, café au lait trifecta that strong blue mountain cortado aftertaste. Aroma extraction french press, skinny sweet, blue mountain cup roast barista, beans, extra cappuccino mug crema strong. Americano caffeine white, con panna saucer sit, con panna eu, carajillo aftertaste kopi-luwak, body aftertaste cup single origin café au lait saucer

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+
+
+
+" +`; + +exports[`State Open 1`] = ` +" + + +
+
+

+

Closed state 'Dialog card' element title

+ + +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+
+
+ + +
+
+
+ +

This is the open Custom 'Dialog' Element title

+ + +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+
+
+
+" +`; diff --git a/src/Component/Dialog/dialog.css b/src/Component/Dialog/dialog.css new file mode 100644 index 0000000..d960bb0 --- /dev/null +++ b/src/Component/Dialog/dialog.css @@ -0,0 +1,109 @@ +/** + * Dialog + */ + +@layer design-system.defaults { + dialog { + position: relative; + } + + :is(mx-dialog) { + display: block; + } +} + +@layer design-system.components { + /* start to look at making body not scroll when Dialog is open */ + body:has(.mx-dialog__card .mx-dialog__element[data-state="open"]) { + overflow-y: hidden; + } + + .mx-dialog__card { + margin-block-end: var(--spacing-l); + + & article { + inline-size: 100%; + padding: var(--spacing-l); + } + + & button { + mask-size: var(--spacing-l); + appearance: none; + border: none; + background-color: var(--colour-primary-light); + display: block; + } + + &:has(.mx-dialog__element[data-state="open"]) { + --line-colour: tranparent; + + border: 0; + } + } + + .mx-dialog__title { + margin-block-end: var(--spacing-m); + display: flex; + flex-flow: row wrap; + gap: var(--spacing-s); + align-items: center; + } + + .mx-dialog__element__close, + .mx-dialog__toggle { + cursor: pointer; + inline-size: auto; + justify-content: space-between; + } + + .mx-dialog__element { + display: none; + opacity: 0; + overflow: hidden; + block-size: 1px; + + &[data-state="open"] { + position: fixed; + opacity: 1; + display: block; + inset: 0; + inline-size: 100%; + block-size: 100%; + background-color: rgb(0 0 0 / 70%); + z-index: 10; + + @starting-style { + opacity: 0; + display: block; + inset-block-end: 0; + inline-size: 5%; + block-size: 1px; + } + + & .mx-dialog__content { + --scheme: var(--alt-scheme, var(--colour-scheme)); + --background: var(--colour-background-alt); + + background-color: var(--background); + margin: auto; + inset: 2rem; + position: absolute; + z-index: 20; + padding: 2rem; + overflow-y: auto; + } + } + } +} + +/** + * Print stylesheet + */ + +@media print { + .mx-dialog { + & .mx-dialog__content { + display: block !important; + } + } +} diff --git a/src/Component/Dialog/dialog.entry.js b/src/Component/Dialog/dialog.entry.js new file mode 100644 index 0000000..e4ae648 --- /dev/null +++ b/src/Component/Dialog/dialog.entry.js @@ -0,0 +1 @@ +import "./Elements/Dialog" diff --git a/src/Component/Dialog/dialog.twig b/src/Component/Dialog/dialog.twig new file mode 100644 index 0000000..14de78e --- /dev/null +++ b/src/Component/Dialog/dialog.twig @@ -0,0 +1,33 @@ +{% set baseClass = 'mx-dialog__element' %} +{% set classes = [ + baseClass, +] %} +{% set attributes = (attributes ?? create_attribute()).addClass(classes) %} + +{% set dialogState = state ? "open" : "closed"%} + + +
+
+

{{title}}

+ +
+ {{content}} +
+
+ + + +
+
+ {{ dialogTitle }} + +
+
{{ dialogContent }}
+
+ +