From 43686283712c232d9254b158080e38f4e1402b5e Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 11 Sep 2026 21:58:01 +0000 Subject: [PATCH 1/3] ui-patterns: shared project components The pieces of a "your projects" page that ml-trainer and the Python Editor prototype had each written, lifted from ml-trainer with the app-specific parts turned into props: - ProjectCard: actions row (optional selection checkbox, more menu with open, rename, duplicate and delete, hidden skip-to-toolbar link for keyboard users), the app's own content in the middle, the name as the card's link and when it was last modified. Apps pass a ProjectSummary (id, name, timestamp) or a superset. - ProjectsToolbar, SearchInput, SortInput: the controls above the grid. - NameProjectDialog and ConfirmDialog. - useProjectActions: the rename, duplicate and delete flows behind the card menus and the toolbar. Renders the two dialogs, tracks which project is being acted on and returns focus to the menu button that opened a dialog. The app supplies what each action does and logs what it wants. - rankProjects, sortProjects and useProjectSelection for the list state, with the app supplying secondary search terms (file names, action names). formatTimeAgo for the card's relative time. Page layout, the grid, the home page carousel and where the toolbar sits at each width stay app-side; the "Projects page example" story shows one composition. Short-viewport tweaks that used ml-trainer's own Panda condition go through the card's bodyCss prop rather than into the shared code. --- packages/ui-patterns/README.md | 6 + packages/ui-patterns/lang/ui.en.json | 112 +++++++ packages/ui-patterns/src/index.ts | 1 + .../src/projects/ConfirmDialog.tsx | 75 +++++ .../src/projects/NameProjectDialog.tsx | 146 +++++++++ .../ui-patterns/src/projects/ProjectCard.tsx | 291 ++++++++++++++++++ .../src/projects/ProjectsToolbar.tsx | 174 +++++++++++ .../ui-patterns/src/projects/SearchInput.tsx | 82 +++++ .../ui-patterns/src/projects/SortInput.tsx | 95 ++++++ packages/ui-patterns/src/projects/index.ts | 15 + .../ui-patterns/src/projects/project-list.ts | 148 +++++++++ packages/ui-patterns/src/projects/time-ago.ts | 40 +++ packages/ui-patterns/src/projects/types.ts | 22 ++ .../src/projects/useProjectActions.tsx | 189 ++++++++++++ .../stories/ConfirmDialog.stories.tsx | 53 ++++ .../stories/NameProjectDialog.stories.tsx | 81 +++++ .../stories/ProjectCard.stories.tsx | 105 +++++++ .../stories/ProjectsPageExample.stories.tsx | 245 +++++++++++++++ .../stories/ProjectsToolbar.stories.tsx | 48 +++ .../stories/SearchInput.stories.tsx | 40 +++ .../ui-patterns/stories/SortInput.stories.tsx | 67 ++++ .../ui-patterns/tests/ProjectCard.test.tsx | 234 ++++++++++++++ .../tests/project-controls.test.tsx | 146 +++++++++ .../tests/project-dialogs.test.tsx | 139 +++++++++ .../ui-patterns/tests/project-list.test.ts | 136 ++++++++ packages/ui-patterns/tests/time-ago.test.ts | 34 ++ 26 files changed, 2724 insertions(+) create mode 100644 packages/ui-patterns/src/projects/ConfirmDialog.tsx create mode 100644 packages/ui-patterns/src/projects/NameProjectDialog.tsx create mode 100644 packages/ui-patterns/src/projects/ProjectCard.tsx create mode 100644 packages/ui-patterns/src/projects/ProjectsToolbar.tsx create mode 100644 packages/ui-patterns/src/projects/SearchInput.tsx create mode 100644 packages/ui-patterns/src/projects/SortInput.tsx create mode 100644 packages/ui-patterns/src/projects/index.ts create mode 100644 packages/ui-patterns/src/projects/project-list.ts create mode 100644 packages/ui-patterns/src/projects/time-ago.ts create mode 100644 packages/ui-patterns/src/projects/types.ts create mode 100644 packages/ui-patterns/src/projects/useProjectActions.tsx create mode 100644 packages/ui-patterns/stories/ConfirmDialog.stories.tsx create mode 100644 packages/ui-patterns/stories/NameProjectDialog.stories.tsx create mode 100644 packages/ui-patterns/stories/ProjectCard.stories.tsx create mode 100644 packages/ui-patterns/stories/ProjectsPageExample.stories.tsx create mode 100644 packages/ui-patterns/stories/ProjectsToolbar.stories.tsx create mode 100644 packages/ui-patterns/stories/SearchInput.stories.tsx create mode 100644 packages/ui-patterns/stories/SortInput.stories.tsx create mode 100644 packages/ui-patterns/tests/ProjectCard.test.tsx create mode 100644 packages/ui-patterns/tests/project-controls.test.tsx create mode 100644 packages/ui-patterns/tests/project-dialogs.test.tsx create mode 100644 packages/ui-patterns/tests/project-list.test.ts create mode 100644 packages/ui-patterns/tests/time-ago.test.ts diff --git a/packages/ui-patterns/README.md b/packages/ui-patterns/README.md index 6dd3aba..59749f6 100644 --- a/packages/ui-patterns/README.md +++ b/packages/ui-patterns/README.md @@ -28,6 +28,12 @@ blocks. pass a narrowed set where landing a user in a language silently would be wrong (e.g. incomplete native-app translations). +- **Project components** — the building blocks of a "your projects" page: + `ProjectCard`, the selection toolbar, search and sort controls, the + name and confirm dialogs, and `useProjectActions` for the rename, + duplicate and delete flows behind them. Page layout and the grid stay + app-side; see the "Projects/Page example" story for a composition. + - **Error views** — the full-page fallbacks every app carries. `UnexpectedErrorPage` says an unexpected error occurred, links to the app's support site, offers a reload, and shows an optional error diff --git a/packages/ui-patterns/lang/ui.en.json b/packages/ui-patterns/lang/ui.en.json index 53b08c6..bdf99d5 100644 --- a/packages/ui-patterns/lang/ui.en.json +++ b/packages/ui-patterns/lang/ui.en.json @@ -1,4 +1,12 @@ { + "ui-patterns.cancel-action": { + "defaultMessage": "Cancel", + "description": "Cancel button in dialogs" + }, + "ui-patterns.clear-action": { + "defaultMessage": "Clear", + "description": "Clears a search box or a selection" + }, "ui-patterns.copied-feedback": { "defaultMessage": "Copied", "description": "Accessible name of the copy button briefly after copying the error reference" @@ -7,6 +15,34 @@ "defaultMessage": "Copy error reference", "description": "Accessible name of the button that copies the error reference" }, + "ui-patterns.delete-project-action": { + "defaultMessage": "{count, plural, one {Delete} other {Delete {count} projects}}", + "description": "Delete action for one project or a selection of projects" + }, + "ui-patterns.delete-project-confirm-heading": { + "defaultMessage": "Confirm delete project", + "description": "Heading of the dialog confirming deletion of one project" + }, + "ui-patterns.delete-project-confirm-text": { + "defaultMessage": "Are you sure you want to delete the project \"{project}\"?", + "description": "Body of the dialog confirming deletion of one project" + }, + "ui-patterns.delete-projects-confirm-heading": { + "defaultMessage": "Confirm delete projects", + "description": "Heading of the dialog confirming deletion of several projects" + }, + "ui-patterns.delete-projects-confirm-text": { + "defaultMessage": "Are you sure you want to delete {numProjects} projects?", + "description": "Body of the dialog confirming deletion of several projects" + }, + "ui-patterns.duplicate-project-action": { + "defaultMessage": "Duplicate", + "description": "Duplicate project action" + }, + "ui-patterns.duplicate-project-heading": { + "defaultMessage": "Duplicate project", + "description": "Heading of the dialog naming a duplicated project" + }, "ui-patterns.error-reference": { "defaultMessage": "Error reference: {id}", "description": "Shown on the unexpected error page. The id identifies the error report and can be quoted in support requests." @@ -47,6 +83,14 @@ "defaultMessage": "Language not fully supported", "description": "Language support toast notification title" }, + "ui-patterns.name-label": { + "defaultMessage": "Name", + "description": "Label of the project name field" + }, + "ui-patterns.name-project-heading": { + "defaultMessage": "Name your project", + "description": "Default heading of the project name dialog" + }, "ui-patterns.not-found-home-link": { "defaultMessage": "Go to home page", "description": "Link text on the page not found page" @@ -55,18 +99,86 @@ "defaultMessage": "Page not found", "description": "Title of the page shown for an unknown URL" }, + "ui-patterns.open-project-action": { + "defaultMessage": "Open", + "description": "Open project action" + }, "ui-patterns.play-video": { "defaultMessage": "Play video: {title}", "description": "Accessible label for the play button shown over a video preview image; title is the video's title" }, + "ui-patterns.project-menu-label": { + "defaultMessage": "{name} actions menu", + "description": "Accessible name of a project card's actions menu button" + }, + "ui-patterns.project-name-not-empty": { + "defaultMessage": "The project name cannot be empty", + "description": "Validation message for the project name field" + }, "ui-patterns.reload-action": { "defaultMessage": "Click to reload the page", "description": "Button on the unexpected error page" }, + "ui-patterns.rename-project-action": { + "defaultMessage": "Rename", + "description": "Rename project action" + }, + "ui-patterns.rename-project-heading": { + "defaultMessage": "Rename project", + "description": "Heading of the dialog renaming a project" + }, + "ui-patterns.search": { + "defaultMessage": "Search", + "description": "Label and placeholder of the projects search box" + }, + "ui-patterns.select-project-action": { + "defaultMessage": "Select {name}", + "description": "Accessible label of a project card's selection checkbox" + }, + "ui-patterns.selection-actions-group": { + "defaultMessage": "Selection actions", + "description": "Accessible name of the toolbar acting on selected projects" + }, + "ui-patterns.skip-to-toolbar-action": { + "defaultMessage": "Skip to toolbar", + "description": "Hidden button on a selected project card that moves focus to the selection toolbar" + }, + "ui-patterns.sort-option-last-modified": { + "defaultMessage": "Last modified", + "description": "Sort projects by last modified" + }, + "ui-patterns.sort-option-name": { + "defaultMessage": "Name", + "description": "Sort projects by name" + }, + "ui-patterns.sort-option-relevance": { + "defaultMessage": "Relevance", + "description": "Shown in the sort control while searching; results are ranked by relevance" + }, + "ui-patterns.sort-order-ascending-label": { + "defaultMessage": "Ascending order", + "description": "Accessible name of the sort direction button when ascending" + }, + "ui-patterns.sort-order-descending-label": { + "defaultMessage": "Descending order", + "description": "Accessible name of the sort direction button when descending" + }, + "ui-patterns.sort-projects-label": { + "defaultMessage": "Sort projects", + "description": "Accessible name of the sort field select" + }, "ui-patterns.support-request": { "defaultMessage": "Please consider raising a support request.", "description": "Text on the unexpected error page. The link opens the support site." }, + "ui-patterns.timestamp-now": { + "defaultMessage": "now", + "description": "Relative time shown for a project modified this second" + }, + "ui-patterns.timestamp-seconds": { + "defaultMessage": "a few seconds ago", + "description": "Relative time shown for a project modified under a minute ago" + }, "ui-patterns.unexpected-error-title": { "defaultMessage": "An unexpected error occurred", "description": "Title of the full-page view shown for an unrecoverable error" diff --git a/packages/ui-patterns/src/index.ts b/packages/ui-patterns/src/index.ts index cbe58bd..7edde6f 100644 --- a/packages/ui-patterns/src/index.ts +++ b/packages/ui-patterns/src/index.ts @@ -14,5 +14,6 @@ export * from "./NotFoundPage"; export * from "./UnexpectedErrorPage"; export * from "./YoutubeVideoEmbed"; export * from "./get-default-language"; +export * from "./projects"; export * from "./languages"; export { uiPatternsMessage } from "./messages"; diff --git a/packages/ui-patterns/src/projects/ConfirmDialog.tsx b/packages/ui-patterns/src/projects/ConfirmDialog.tsx new file mode 100644 index 0000000..d50c9bd --- /dev/null +++ b/packages/ui-patterns/src/projects/ConfirmDialog.tsx @@ -0,0 +1,75 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from "@microbit/ui"; +import { ReactNode, RefObject } from "react"; +import { FormattedMessage } from "react-intl"; +import { uiPatternsMessage } from "../messages"; + +export interface ConfirmDialogProps { + isOpen: boolean; + heading: ReactNode; + body: ReactNode; + onConfirm: () => void; + onCancel: () => void; + /** + * The verb for what confirming does ("Delete", "Reset"). Required: there + * is always a better label than "Confirm". + */ + confirmText: ReactNode; + /** Defaults to "Cancel". */ + cancelText?: ReactNode; + finalFocusRef?: RefObject; + onCloseComplete?: () => void; +} + +/** + * An alert dialog for a destructive action. Cancel takes initial focus, as + * the least destructive choice, and confirm is styled as dangerous. + */ +export const ConfirmDialog = ({ + isOpen, + heading, + body, + onConfirm, + onCancel, + confirmText, + cancelText, + finalFocusRef, + onCloseComplete, +}: ConfirmDialogProps) => ( + + + {heading} + + {body} + + + + + +); diff --git a/packages/ui-patterns/src/projects/NameProjectDialog.tsx b/packages/ui-patterns/src/projects/NameProjectDialog.tsx new file mode 100644 index 0000000..938b5e8 --- /dev/null +++ b/packages/ui-patterns/src/projects/NameProjectDialog.tsx @@ -0,0 +1,146 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Button, + Modal, + ModalBody, + ModalCloseButton, + ModalFooter, + ModalHeader, + TextField, +} from "@microbit/ui"; +import { + FocusEvent, + FormEvent, + ReactNode, + RefObject, + useCallback, + useState, +} from "react"; +import { FormattedMessage } from "react-intl"; +import { uiPatternsMessage } from "../messages"; + +export interface NameProjectDialogProps { + isOpen: boolean; + onClose: () => void; + onSave: (name: string) => void; + /** The name to start from: the current one, or a default for a new project. */ + initialName: string; + /** Defaults to "Name your project". */ + heading?: ReactNode; + /** + * The verb for what saving does ("Create", "Rename", "Confirm and save"). + * Required: there is always a better label than "Confirm". + */ + confirmText: ReactNode; + /** Shown under the field, e.g. what the name is used for. */ + helperText?: ReactNode; + finalFocusRef?: RefObject; + onCloseComplete?: () => void; +} + +export const isValidProjectName = (name: string): boolean => + name.trim().length > 0; + +/** + * Asks for a project name. The field starts with the initial name selected + * so typing replaces it. Submit with Enter or the confirm button; both are + * disabled while the name is blank. + */ +export const NameProjectDialog = ({ + isOpen, + onClose, + onSave, + initialName, + heading, + confirmText, + helperText, + finalFocusRef, + onCloseComplete, +}: NameProjectDialogProps) => { + const [name, setName] = useState(initialName); + // Start afresh from the initial name each time the dialog opens. Adjusting + // state during render, as React recommends for state derived from a prop + // change, rather than an effect that would flash the stale name. + const [wasOpen, setWasOpen] = useState(isOpen); + if (isOpen !== wasOpen) { + setWasOpen(isOpen); + if (isOpen) { + setName(initialName); + } + } + const isValid = isValidProjectName(name); + + const handleFocus = useCallback((event: FocusEvent) => { + event.target.setSelectionRange(0, event.target.value.length); + }, []); + const handleSave = useCallback(() => { + if (isValid) { + onSave(name.trim()); + } + }, [isValid, name, onSave]); + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + handleSave(); + }, + [handleSave], + ); + + return ( + + + {heading ?? ( + + )} + + + +
+ + } + value={name} + onChange={setName} + onFocus={handleFocus} + autoFocus + autoComplete="off" + isRequired + isInvalid={!isValid} + helperText={helperText} + helperTextCss={{ color: "gray.700" }} + errorMessage={ + + } + /> + +
+ + + + +
+ ); +}; diff --git a/packages/ui-patterns/src/projects/ProjectCard.tsx b/packages/ui-patterns/src/projects/ProjectCard.tsx new file mode 100644 index 0000000..acce33f --- /dev/null +++ b/packages/ui-patterns/src/projects/ProjectCard.tsx @@ -0,0 +1,291 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Button, + Card, + CardBody, + Checkbox, + HStack, + Icon, + IconButton, + LinkBox, + LinkOverlayButton, + MenuItem, + MenuList, + MenuTrigger, + Stack, + SystemStyleObject, + Text, + VisuallyHidden, +} from "@microbit/ui"; +import { ReactNode, RefObject, useCallback, useRef } from "react"; +import { MdMoreVert } from "react-icons/md"; +import { + RiDeleteBin2Line, + RiEdit2Line, + RiFileCopyLine, + RiFolderOpenLine, +} from "react-icons/ri"; +import { FormattedMessage, useIntl } from "react-intl"; +import { uiPatternsMessage } from "../messages"; +import { formatTimeAgo } from "./time-ago"; +import { ProjectNameDialogReason, ProjectSummary } from "./types"; + +export interface ProjectCardProps { + project: ProjectSummary; + /** + * The app's picture of the project (a logo, a glyph, a preview), placed + * between the actions row and the name. + */ + children?: ReactNode; + /** One line under the name, e.g. what the project contains. */ + description?: ReactNode; + /** When set the card shows a selection checkbox. */ + isSelected?: boolean; + onSelected?: (id: string) => void; + /** + * When set, a selected card carries a hidden "skip to toolbar" button for + * keyboard users, so they need not tab through every card to act on a + * selection. + */ + onSkipToToolbar?: () => void; + onOpen: (id: string) => void; + onDelete: (id: string) => void; + onRenameDuplicate: (reason: ProjectNameDialogReason, id: string) => void; + /** + * Called with the menu button before a menu action opens a dialog, so the + * dialog can return focus to it. See useProjectActions. + */ + setFinalFocusRef?: (ref: RefObject) => void; + /** Per-instance overrides for the card body, e.g. tighter padding. */ + bodyCss?: SystemStyleObject; + className?: string; +} + +/** + * A project in a grid or carousel: actions row (optional checkbox, more + * menu), the app's content, then the name as the card's link and when it was + * last modified. + */ +export const ProjectCard = ({ + project, + children, + description, + isSelected, + onSelected, + onSkipToToolbar, + onOpen, + onDelete, + onRenameDuplicate, + setFinalFocusRef, + bodyCss, + className, +}: ProjectCardProps) => { + const intl = useIntl(); + const { id, name, timestamp } = project; + const handleOpen = useCallback(() => onOpen(id), [id, onOpen]); + return ( + + + + + + {children} + + {name} + + {description !== undefined && ( + + {description} + + )} + + {formatTimeAgo(intl, timestamp)} + + + + + + ); +}; + +interface ProjectCardActionsProps { + id: string; + name: string; + isSelected?: boolean; + onSelected?: (id: string) => void; + onSkipToToolbar?: () => void; + onOpen: (id: string) => void; + onDelete: (id: string) => void; + onRenameDuplicate: (reason: ProjectNameDialogReason, id: string) => void; + setFinalFocusRef?: (ref: RefObject) => void; +} + +const ProjectCardActions = ({ + id, + name, + isSelected, + onSelected, + onSkipToToolbar, + onOpen, + onDelete, + onRenameDuplicate, + setFinalFocusRef, +}: ProjectCardActionsProps) => { + const intl = useIntl(); + const menuButtonRef = useRef(null); + const withFocusReturn = useCallback( + (action: () => void) => () => { + setFinalFocusRef?.(menuButtonRef); + action(); + }, + [setFinalFocusRef], + ); + return ( + + {onSelected && ( + onSelected(id)} + css={{ + px: 5, + py: 5, + color: "brand.600", + zIndex: 1, + borderColor: "gray.500", + _hover: { backgroundColor: "blackAlpha.50" }, + borderBottomRightRadius: "md", + h: "60px", + }} + > + + + + + )} + {onSkipToToolbar && ( + + )} + + + + + + } + onAction={() => onOpen(id)} + textValue={intl.formatMessage( + uiPatternsMessage("ui-patterns.open-project-action"), + )} + > + + + } + onAction={withFocusReturn(() => onRenameDuplicate("rename", id))} + textValue={intl.formatMessage( + uiPatternsMessage("ui-patterns.rename-project-action"), + )} + > + + + } + onAction={withFocusReturn(() => onRenameDuplicate("duplicate", id))} + textValue={intl.formatMessage( + uiPatternsMessage("ui-patterns.duplicate-project-action"), + )} + > + + + } + onAction={withFocusReturn(() => onDelete(id))} + textValue={intl.formatMessage( + uiPatternsMessage("ui-patterns.delete-project-action"), + { count: 1 }, + )} + > + + + + + + ); +}; diff --git a/packages/ui-patterns/src/projects/ProjectsToolbar.tsx b/packages/ui-patterns/src/projects/ProjectsToolbar.tsx new file mode 100644 index 0000000..2af0f85 --- /dev/null +++ b/packages/ui-patterns/src/projects/ProjectsToolbar.tsx @@ -0,0 +1,174 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Button, ButtonGroup, Icon, IconButton } from "@microbit/ui"; +import { + RiCloseLine, + RiDeleteBin2Line, + RiEdit2Line, + RiFileCopyLine, +} from "react-icons/ri"; +import { FormattedMessage, useIntl } from "react-intl"; +import { uiPatternsMessage } from "../messages"; +import { ProjectNameDialogReason } from "./types"; + +export interface ProjectsToolbarProps { + /** How many projects are selected. Rename and duplicate need exactly one. */ + selectedCount: number; + onRenameDuplicate: (reason: ProjectNameDialogReason) => void; + onDelete: () => void; + onClearSelection: () => void; + /** Icons without labels, for narrow layouts. */ + iconOnly?: boolean; + /** + * Attached: a squared strip with hairline dividers (the default). The + * shared group's pill-with-gap does not suit ghost buttons, which have no + * fill for a gap to separate. + */ + isAttached?: boolean; + size?: "md" | "lg"; + className?: string; +} + +/** + * Actions on the selected projects: rename and duplicate when one is + * selected, delete and clear always. + */ +export const ProjectsToolbar = ({ + selectedCount, + onRenameDuplicate, + onDelete, + onClearSelection, + iconOnly, + isAttached = true, + size, + className, +}: ProjectsToolbarProps) => { + const intl = useIntl(); + const isSingle = selectedCount === 1; + const iconCss = { fontSize: "xl" } as const; + return ( + button": { borderRadius: 0 }, + "& > button + button": { + borderLeft: "1px solid", + borderColor: "gray.200", + }, + } + : undefined + } + > + {isSingle && + (iconOnly ? ( + onRenameDuplicate("rename")} + css={iconCss} + aria-label={intl.formatMessage( + uiPatternsMessage("ui-patterns.rename-project-action"), + )} + > + + + ) : ( + + ))} + {isSingle && + (iconOnly ? ( + onRenameDuplicate("duplicate")} + css={iconCss} + aria-label={intl.formatMessage( + uiPatternsMessage("ui-patterns.duplicate-project-action"), + )} + > + + + ) : ( + + ))} + {iconOnly && isSingle ? ( + + + + ) : ( + + )} + {iconOnly ? ( + + + + ) : ( + + )} + + ); +}; diff --git a/packages/ui-patterns/src/projects/SearchInput.tsx b/packages/ui-patterns/src/projects/SearchInput.tsx new file mode 100644 index 0000000..decd428 --- /dev/null +++ b/packages/ui-patterns/src/projects/SearchInput.tsx @@ -0,0 +1,82 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Icon, + IconButton, + Input, + InputEndElement, + InputGroup, + InputStartElement, +} from "@microbit/ui"; +import { useCallback, useRef } from "react"; +import { RiCloseLine, RiSearch2Line } from "react-icons/ri"; +import { useIntl } from "react-intl"; +import { uiPatternsMessage } from "../messages"; + +export interface SearchInputProps { + value: string; + onChange: (value: string) => void; + /** Accessible name and placeholder. Defaults to "Search". */ + label?: string; + className?: string; +} + +/** + * A rounded search box with a leading icon and a clear button while there + * is text. Clearing returns focus to the box. + */ +export const SearchInput = ({ + value, + onChange, + label, + className, +}: SearchInputProps) => { + const intl = useIntl(); + const ref = useRef(null); + const text = + label ?? intl.formatMessage(uiPatternsMessage("ui-patterns.search")); + const handleClear = useCallback(() => { + onChange(""); + ref.current?.focus(); + }, [onChange]); + return ( + + + + + onChange(e.target.value)} + type="text" + css={{ + ps: 10, + pe: 10, + fontSize: "lg", + _placeholder: { color: "gray.500" }, + borderRadius: "20px", + background: "white", + }} + /> + {value && ( + + + + + + )} + + ); +}; diff --git a/packages/ui-patterns/src/projects/SortInput.tsx b/packages/ui-patterns/src/projects/SortInput.tsx new file mode 100644 index 0000000..ddfb142 --- /dev/null +++ b/packages/ui-patterns/src/projects/SortInput.tsx @@ -0,0 +1,95 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { ButtonGroup, Icon, IconButton, NativeSelect } from "@microbit/ui"; +import { RiArrowDownLine, RiArrowUpLine } from "react-icons/ri"; +import { useIntl } from "react-intl"; +import { uiPatternsMessage } from "../messages"; +import { ProjectSortField, SortDirection } from "./types"; + +export interface SortInputProps { + field: ProjectSortField; + onFieldChange: (field: ProjectSortField) => void; + direction: SortDirection; + onToggleDirection: () => void; + /** + * While searching, results are ranked by relevance and the controls are + * disabled, showing "Relevance" in place of the field. + */ + hasSearchQuery: boolean; + className?: string; +} + +/** + * Sort field select with an attached direction button. + */ +export const SortInput = ({ + field, + onFieldChange, + direction, + onToggleDirection, + hasSearchQuery, + className, +}: SortInputProps) => { + const intl = useIntl(); + const message = (id: Parameters[0]) => + intl.formatMessage(uiPatternsMessage(id)); + const descending = hasSearchQuery || direction === "desc"; + return ( + + onFieldChange(e.target.value as ProjectSortField)} + aria-label={message("ui-patterns.sort-projects-label")} + // The adjacent direction button keeps the control from reading as a + // plain text field. + hideChevron + disabled={hasSearchQuery} + // Lets the select shrink below its longest option when space is + // tight, clipping the text. + css={{ fontSize: "lg", background: "white", flex: 1, minW: 0 }} + > + {hasSearchQuery ? ( + + ) : ( + <> + + + + )} + + + + + + ); +}; diff --git a/packages/ui-patterns/src/projects/index.ts b/packages/ui-patterns/src/projects/index.ts new file mode 100644 index 0000000..09fdd9f --- /dev/null +++ b/packages/ui-patterns/src/projects/index.ts @@ -0,0 +1,15 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +export * from "./ConfirmDialog"; +export * from "./NameProjectDialog"; +export * from "./ProjectCard"; +export * from "./ProjectsToolbar"; +export * from "./SearchInput"; +export * from "./SortInput"; +export * from "./project-list"; +export * from "./time-ago"; +export * from "./types"; +export * from "./useProjectActions"; diff --git a/packages/ui-patterns/src/projects/project-list.ts b/packages/ui-patterns/src/projects/project-list.ts new file mode 100644 index 0000000..468c7f2 --- /dev/null +++ b/packages/ui-patterns/src/projects/project-list.ts @@ -0,0 +1,148 @@ +/** + * List state shared by the projects pages: ranking a search, sorting, and + * a multi-selection that forgets projects that disappear. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { useCallback, useMemo, useReducer } from "react"; +import { ProjectSortField, ProjectSummary, SortDirection } from "./types"; + +/** + * Ranks projects against a search query. Every term must match the name or + * one of the app's secondary terms (file names, action names). Name matches + * outrank secondary matches; exact beats prefix beats substring. Projects + * with no match are dropped. + */ +export const rankProjects =

( + projects: P[], + query: string, + secondaryTerms: (project: P) => string[] = () => [], +): P[] => { + const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean); + if (terms.length === 0) { + return projects; + } + const ranked: Array<{ project: P; score: number }> = []; + for (const project of projects) { + const name = project.name.toLowerCase(); + const secondary = secondaryTerms(project).map((t) => t.toLowerCase()); + const allMatch = terms.every( + (term) => name.includes(term) || secondary.some((s) => s.includes(term)), + ); + if (!allMatch) { + continue; + } + let score = 0; + for (const term of terms) { + if (name === term) { + score += 100; + } else if (name.startsWith(term)) { + score += 50; + } else if (name.includes(term)) { + score += 30; + } + for (const s of secondary) { + if (s === term) { + score += 15; + } else if (s.startsWith(term)) { + score += 8; + } else if (s.includes(term)) { + score += 5; + } + } + } + ranked.push({ project, score }); + } + return ranked.sort((a, b) => b.score - a.score).map((r) => r.project); +}; + +export const sortProjects =

( + projects: P[], + field: ProjectSortField, + direction: SortDirection, +): P[] => { + const sorted = [...projects].sort((a, b) => + field === "name" + ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + : a.timestamp - b.timestamp, + ); + return direction === "desc" ? sorted.reverse() : sorted; +}; + +/** The default direction when switching to a field: newest first, A to Z. */ +export const defaultSortDirection = (field: ProjectSortField): SortDirection => + field === "name" ? "asc" : "desc"; + +export interface ProjectSelection { + selectedIds: string[]; + hasSelection: boolean; + /** + * The selection as it was when last non-empty. For a toolbar that slides + * out on clearing, so it does not change shape mid-animation. + */ + lastSelectedIds: string[]; + isSelected: (id: string) => boolean; + toggle: (id: string) => void; + clear: () => void; +} + +interface SelectionState { + selected: string[]; + last: string[]; +} + +type SelectionAction = { type: "toggle"; id: string } | { type: "clear" }; + +const selectionReducer = ( + state: SelectionState, + action: SelectionAction, +): SelectionState => { + switch (action.type) { + case "toggle": { + const selected = state.selected.includes(action.id) + ? state.selected.filter((v) => v !== action.id) + : [...state.selected, action.id]; + return { selected, last: selected.length > 0 ? selected : state.last }; + } + case "clear": + return { selected: [], last: state.last }; + } +}; + +const noSelection: SelectionState = { selected: [], last: [] }; + +/** + * Multi-selection of projects by id. A project that leaves the list (deleted, + * perhaps in another tab) leaves the selection too. + */ +export const useProjectSelection = ( + projects: ProjectSummary[], +): ProjectSelection => { + const [state, dispatch] = useReducer(selectionReducer, noSelection); + const selectedIds = useMemo(() => { + const ids = new Set(projects.map((p) => p.id)); + return state.selected.filter((id) => ids.has(id)); + }, [projects, state.selected]); + const toggle = useCallback( + (id: string) => dispatch({ type: "toggle", id }), + [], + ); + const clear = useCallback(() => dispatch({ type: "clear" }), []); + const isSelected = useCallback( + (id: string) => selectedIds.includes(id), + [selectedIds], + ); + return useMemo( + () => ({ + selectedIds, + hasSelection: selectedIds.length > 0, + lastSelectedIds: state.last, + isSelected, + toggle, + clear, + }), + [selectedIds, state.last, isSelected, toggle, clear], + ); +}; diff --git a/packages/ui-patterns/src/projects/time-ago.ts b/packages/ui-patterns/src/projects/time-ago.ts new file mode 100644 index 0000000..a5c14d6 --- /dev/null +++ b/packages/ui-patterns/src/projects/time-ago.ts @@ -0,0 +1,40 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { IntlShape } from "react-intl"; +import { uiPatternsMessage } from "../messages"; + +const units: Array<{ unit: Intl.RelativeTimeFormatUnit; seconds: number }> = [ + { unit: "year", seconds: 31536000 }, + { unit: "month", seconds: 2592000 }, + { unit: "day", seconds: 86400 }, + { unit: "hour", seconds: 3600 }, + { unit: "minute", seconds: 60 }, + { unit: "second", seconds: 1 }, +]; + +/** + * "3 days ago", in the intl locale. Sub-minute times say "a few seconds ago" + * rather than counting, since the card does not re-render every second. + */ +export const formatTimeAgo = ( + intl: IntlShape, + timestamp: number, + now: number = Date.now(), +): string => { + const diffInSeconds = (timestamp - now) / 1000; + for (const { unit, seconds } of units) { + const interval = Math.round(diffInSeconds / seconds); + if (Math.abs(interval) >= 1) { + if (unit === "second") { + return intl.formatMessage( + uiPatternsMessage("ui-patterns.timestamp-seconds"), + ); + } + return intl.formatRelativeTime(interval, unit); + } + } + return intl.formatMessage(uiPatternsMessage("ui-patterns.timestamp-now")); +}; diff --git a/packages/ui-patterns/src/projects/types.ts b/packages/ui-patterns/src/projects/types.ts new file mode 100644 index 0000000..8349fca --- /dev/null +++ b/packages/ui-patterns/src/projects/types.ts @@ -0,0 +1,22 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +/** + * What the project components need to know about a project. Apps keep their + * own richer records and pass these, or a superset. + */ +export interface ProjectSummary { + id: string; + name: string; + /** Last modified or opened, in milliseconds since the epoch. */ + timestamp: number; +} + +export type ProjectNameDialogReason = "rename" | "duplicate"; + +export type ProjectSortField = "name" | "timestamp"; + +export type SortDirection = "asc" | "desc"; diff --git a/packages/ui-patterns/src/projects/useProjectActions.tsx b/packages/ui-patterns/src/projects/useProjectActions.tsx new file mode 100644 index 0000000..3afc1ea --- /dev/null +++ b/packages/ui-patterns/src/projects/useProjectActions.tsx @@ -0,0 +1,189 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Text } from "@microbit/ui"; +import { ReactNode, RefObject, useCallback, useState } from "react"; +import { FormattedMessage } from "react-intl"; +import { uiPatternsMessage } from "../messages"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { NameProjectDialog } from "./NameProjectDialog"; +import { ProjectNameDialogReason, ProjectSummary } from "./types"; + +export interface UseProjectActionsOptions { + projects: ProjectSummary[]; + onRename: (id: string, name: string) => void | Promise; + onDuplicate: (id: string, name: string) => void | Promise; + /** One id from a card's menu, or the selection from the toolbar. */ + onDelete: (ids: string[]) => void | Promise; + /** + * The current selection, for toolbar actions that name no project. Rename + * and duplicate act only when exactly one project is selected. + */ + getSelectedIds?: () => string[]; +} + +export interface ProjectActions { + /** + * The name and confirm dialogs, wired up. Render once on the page. They + * return focus to the card menu button that opened them. + */ + dialogs: ReactNode; + /** For ProjectCard, so a dialog can return focus to the menu button. */ + setFinalFocusRef: (ref: RefObject) => void; + /** Opens the name dialog. Without an id, acts on the single selection. */ + renameOrDuplicate: (reason: ProjectNameDialogReason, id?: string) => void; + /** Opens the confirm dialog. Without an id, acts on the selection. */ + requestDelete: (id?: string) => void; +} + +/** + * The rename, duplicate and delete flows behind ProjectCard menus and the + * ProjectsToolbar: which project is being acted on, the dialogs, and focus + * return. The app supplies what each action does and logs what it wants. + */ +export const useProjectActions = ({ + projects, + onRename, + onDuplicate, + onDelete, + getSelectedIds, +}: UseProjectActionsOptions): ProjectActions => { + const [target, setTarget] = useState(); + const [nameReason, setNameReason] = useState(); + const [confirming, setConfirming] = useState(false); + const [finalFocusRef, setFinalFocusRef] = useState< + RefObject | undefined + >(); + const clearFinalFocusRef = useCallback(() => setFinalFocusRef(undefined), []); + + const resolve = useCallback( + (id?: string): ProjectSummary | undefined => { + const selected = getSelectedIds?.() ?? []; + const resolvedId = + id ?? (selected.length === 1 ? selected[0] : undefined); + return projects.find((p) => p.id === resolvedId); + }, + [getSelectedIds, projects], + ); + + const renameOrDuplicate = useCallback( + (reason: ProjectNameDialogReason, id?: string) => { + const project = resolve(id); + if (project) { + setTarget(project); + setNameReason(reason); + } + }, + [resolve], + ); + const closeNameDialog = useCallback(() => setNameReason(undefined), []); + const saveName = useCallback( + async (name: string) => { + const project = target; + const reason = nameReason; + closeNameDialog(); + if (project && reason) { + await (reason === "rename" + ? onRename(project.id, name) + : onDuplicate(project.id, name)); + } + }, + [closeNameDialog, nameReason, onDuplicate, onRename, target], + ); + + const requestDelete = useCallback( + (id?: string) => { + const project = id ? resolve(id) : undefined; + setTarget(project); + if (project || (getSelectedIds?.().length ?? 0) > 0) { + setConfirming(true); + } + }, + [getSelectedIds, resolve], + ); + const closeConfirm = useCallback(() => setConfirming(false), []); + const confirmDelete = useCallback(async () => { + const ids = target ? [target.id] : getSelectedIds?.() ?? []; + closeConfirm(); + if (ids.length > 0) { + await onDelete(ids); + } + }, [closeConfirm, getSelectedIds, onDelete, target]); + + const selectedCount = getSelectedIds?.().length ?? 0; + const dialogs = ( + <> + + } + confirmText={ + + } + /> + + } + body={ + + {target ? ( + + ) : ( + + )} + + } + confirmText={ + + } + onConfirm={confirmDelete} + onCancel={closeConfirm} + onCloseComplete={clearFinalFocusRef} + finalFocusRef={finalFocusRef} + /> + + ); + + return { dialogs, setFinalFocusRef, renameOrDuplicate, requestDelete }; +}; diff --git a/packages/ui-patterns/stories/ConfirmDialog.stories.tsx b/packages/ui-patterns/stories/ConfirmDialog.stories.tsx new file mode 100644 index 0000000..2a8fb7b --- /dev/null +++ b/packages/ui-patterns/stories/ConfirmDialog.stories.tsx @@ -0,0 +1,53 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Box, Button, Text, useDisclosure } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConfirmDialog } from "../src"; + +const meta = { + title: "Projects/ConfirmDialog", + component: ConfirmDialog, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const Harness = ({ confirmText }: { confirmText: string }) => { + const disclosure = useDisclosure(); + return ( + + + Are you sure you want to delete the project "Heart"?} + confirmText={confirmText} + onConfirm={() => { + alert("deleted"); + disclosure.onClose(); + }} + onCancel={disclosure.onClose} + /> + + ); +}; + +const args = { + isOpen: true, + heading: "Confirm delete project", + body: "", + confirmText: "Delete", + onConfirm: () => {}, + onCancel: () => {}, +}; + +/** Cancel takes focus first; the confirm verb is styled as dangerous. */ +export const Default: Story = { + args, + render: () => , +}; diff --git a/packages/ui-patterns/stories/NameProjectDialog.stories.tsx b/packages/ui-patterns/stories/NameProjectDialog.stories.tsx new file mode 100644 index 0000000..f96739c --- /dev/null +++ b/packages/ui-patterns/stories/NameProjectDialog.stories.tsx @@ -0,0 +1,81 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Box, Button, useDisclosure } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReactNode } from "react"; +import { NameProjectDialog, NameProjectDialogProps } from "../src"; + +const meta = { + title: "Projects/NameProjectDialog", + component: NameProjectDialog, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +type HarnessProps = Omit< + NameProjectDialogProps, + "isOpen" | "onClose" | "onSave" | "confirmText" +> & + Partial>; + +const Harness = (props: HarnessProps) => { + const disclosure = useDisclosure(); + return ( + + + { + alert(`saved: ${name}`); + disclosure.onClose(); + }} + /> + + ); +}; + +const args = { + isOpen: true, + onClose: () => {}, + onSave: () => {}, + initialName: "Untitled project", + confirmText: "Create", +}; + +/** Naming a new project: the default heading. */ +export const NewProject: Story = { + args, + render: ({ initialName }) => , +}; + +/** With app-specific helper text under the field. */ +export const WithHelperText: Story = { + args, + render: ({ initialName }) => ( + + ), +}; + +/** Renaming: the app passes the heading and confirm text. */ +export const Rename: Story = { + args: { ...args, initialName: "Heartbeat monitor" }, + render: ({ initialName }) => ( + + ), +}; diff --git a/packages/ui-patterns/stories/ProjectCard.stories.tsx b/packages/ui-patterns/stories/ProjectCard.stories.tsx new file mode 100644 index 0000000..39fd8ff --- /dev/null +++ b/packages/ui-patterns/stories/ProjectCard.stories.tsx @@ -0,0 +1,105 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Box, css, Grid } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProjectCard } from "../src"; + +const meta = { + title: "Projects/ProjectCard", + component: ProjectCard, + args: { + project: { + id: "p1", + name: "Heartbeat monitor", + timestamp: Date.now() - 3 * 3_600_000, + }, + onOpen: (id) => alert(`open ${id}`), + onDelete: (id) => alert(`delete ${id}`), + onRenameDuplicate: (reason, id) => alert(`${reason} ${id}`), + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Stands in for an app's logo or glyph. */ +const Glyph = () => ( + + + +); + +export const Default: Story = { + args: { children: }, +}; + +/** A line under the name for what the project contains. */ +export const WithDescription: Story = { + args: { children: , description: "main.py, sensors.py" }, +}; + +/** As on a projects page: selectable, with the hidden skip-to-toolbar link. */ +export const Selectable: Story = { + args: { + children: , + isSelected: true, + onSelected: (id) => alert(`toggle ${id}`), + onSkipToToolbar: () => alert("skip to toolbar"), + }, +}; + +export const LongName: Story = { + args: { + children: , + project: { + id: "p2", + name: "A project with a name far too long to fit on the card", + timestamp: Date.now() - 40 * 86_400_000, + }, + }, +}; + +/** Equal heights in a grid, as the projects page lays them out. */ +export const InAGrid: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + render: (args) => ( + + {["Heart", "Radio messenger", "Step counter", "Night light"].map( + (name, i) => ( + + + + + + ), + )} + + ), +}; diff --git a/packages/ui-patterns/stories/ProjectsPageExample.stories.tsx b/packages/ui-patterns/stories/ProjectsPageExample.stories.tsx new file mode 100644 index 0000000..68552de --- /dev/null +++ b/packages/ui-patterns/stories/ProjectsPageExample.stories.tsx @@ -0,0 +1,245 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Box, + css, + cx, + Flex, + Grid, + HStack, + Slide, + Text, + useBreakpointValue, + VStack, +} from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMemo, useState } from "react"; +import { + defaultSortDirection, + ProjectCard, + ProjectSortField, + ProjectsToolbar, + rankProjects, + SearchInput, + SortDirection, + SortInput, + sortProjects, + useProjectActions, + useProjectSelection, +} from "../src"; + +interface ExampleProject { + id: string; + name: string; + timestamp: number; + files: string[]; +} + +const day = 86_400_000; +const initialProjects: ExampleProject[] = [ + { + id: "1", + name: "Heart", + timestamp: Date.now() - 2 * 3_600_000, + files: ["main.py"], + }, + { + id: "2", + name: "Heartbeat monitor", + timestamp: Date.now() - day, + files: ["main.py", "sensor.py"], + }, + { + id: "3", + name: "Radio messenger", + timestamp: Date.now() - 3 * day, + files: ["main.py", "radio.py"], + }, + { + id: "4", + name: "Step counter", + timestamp: Date.now() - 12 * day, + files: ["main.py"], + }, + { + id: "5", + name: "Night light", + timestamp: Date.now() - 40 * day, + files: ["main.py", "light.py"], + }, +]; + +const Glyph = () => ( + + + +); + +/** + * How an app composes the pieces into a projects page. Everything here is + * app-side: the page layout, the grid, where the toolbar goes at each + * width, and what rename, duplicate and delete actually do. + */ +const ProjectsPageExample = () => { + const [projects, setProjects] = useState(initialProjects); + const [query, setQuery] = useState(""); + const [field, setField] = useState("timestamp"); + const [direction, setDirection] = useState("desc"); + const selection = useProjectSelection(projects); + const mobileIconOnly = useBreakpointValue({ base: true, md: false }); + + const actions = useProjectActions({ + projects, + getSelectedIds: () => selection.selectedIds, + onRename: (id, name) => + setProjects((ps) => ps.map((p) => (p.id === id ? { ...p, name } : p))), + onDuplicate: (id, name) => + setProjects((ps) => { + const source = ps.find((p) => p.id === id)!; + return [ + ...ps, + { ...source, id: String(Date.now()), name, timestamp: Date.now() }, + ]; + }), + onDelete: (ids) => + setProjects((ps) => ps.filter((p) => !ids.includes(p.id))), + }); + + const shown = useMemo( + () => + query.trim() + ? rankProjects(projects, query, (p) => p.files) + : sortProjects(projects, field, direction), + [direction, field, projects, query], + ); + + return ( + <> + {actions.dialogs} + + + + { + if (value.trim()) { + selection.clear(); + } + setQuery(value); + }} + className={css({ maxW: "30ch" })} + /> + {selection.hasSelection && ( + + + + )} + { + setField(next); + setDirection(defaultSortDirection(next)); + }} + onToggleDirection={() => + setDirection((d) => (d === "asc" ? "desc" : "asc")) + } + hasSearchQuery={!!query.trim()} + /> + + {shown.length > 0 ? ( + + {shown.map((project) => ( + + alert(`open ${id}`)} + onDelete={actions.requestDelete} + onRenameDuplicate={actions.renameOrDuplicate} + setFinalFocusRef={actions.setFinalFocusRef} + > + + + + ))} + + ) : ( + + No projects to display + + )} + + + + + + + + + ); +}; + +const meta = { + title: "Projects/Page example", + component: ProjectsPageExample, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/ui-patterns/stories/ProjectsToolbar.stories.tsx b/packages/ui-patterns/stories/ProjectsToolbar.stories.tsx new file mode 100644 index 0000000..38e08ed --- /dev/null +++ b/packages/ui-patterns/stories/ProjectsToolbar.stories.tsx @@ -0,0 +1,48 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Box } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProjectsToolbar } from "../src"; + +const meta = { + title: "Projects/ProjectsToolbar", + component: ProjectsToolbar, + args: { + selectedCount: 1, + onRenameDuplicate: (reason) => alert(reason), + onDelete: () => alert("delete"), + onClearSelection: () => alert("clear"), + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** One project selected: rename and duplicate are offered. */ +export const SingleSelection: Story = {}; + +/** Several selected: only delete (with the count) and clear. */ +export const MultipleSelection: Story = { + args: { selectedCount: 3 }, +}; + +/** The narrow layout: icons only, detached, larger targets. */ +export const IconOnly: Story = { + args: { iconOnly: true, isAttached: false, size: "lg" }, +}; diff --git a/packages/ui-patterns/stories/SearchInput.stories.tsx b/packages/ui-patterns/stories/SearchInput.stories.tsx new file mode 100644 index 0000000..dfbe61c --- /dev/null +++ b/packages/ui-patterns/stories/SearchInput.stories.tsx @@ -0,0 +1,40 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Box } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { SearchInput } from "../src"; + +const meta = { + title: "Projects/SearchInput", + component: SearchInput, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const Controlled = ({ initial }: { initial: string }) => { + const [value, setValue] = useState(initial); + return ; +}; + +export const Empty: Story = { + args: { value: "", onChange: () => {} }, + render: () => , +}; + +/** With text the clear button appears; clearing returns focus to the box. */ +export const WithText: Story = { + args: { value: "heart", onChange: () => {} }, + render: () => , +}; diff --git a/packages/ui-patterns/stories/SortInput.stories.tsx b/packages/ui-patterns/stories/SortInput.stories.tsx new file mode 100644 index 0000000..5718d61 --- /dev/null +++ b/packages/ui-patterns/stories/SortInput.stories.tsx @@ -0,0 +1,67 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Box } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { + defaultSortDirection, + ProjectSortField, + SortDirection, + SortInput, +} from "../src"; + +const meta = { + title: "Projects/SortInput", + component: SortInput, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const Controlled = ({ hasSearchQuery }: { hasSearchQuery: boolean }) => { + const [field, setField] = useState("timestamp"); + const [direction, setDirection] = useState("desc"); + return ( + { + setField(next); + setDirection(defaultSortDirection(next)); + }} + onToggleDirection={() => + setDirection((d) => (d === "asc" ? "desc" : "asc")) + } + hasSearchQuery={hasSearchQuery} + /> + ); +}; + +const args = { + field: "timestamp" as const, + direction: "desc" as const, + onFieldChange: () => {}, + onToggleDirection: () => {}, + hasSearchQuery: false, +}; + +export const Default: Story = { + args, + render: () => , +}; + +/** While searching the list is ranked by relevance and the controls rest. */ +export const WhileSearching: Story = { + args: { ...args, hasSearchQuery: true }, + render: () => , +}; diff --git a/packages/ui-patterns/tests/ProjectCard.test.tsx b/packages/ui-patterns/tests/ProjectCard.test.tsx new file mode 100644 index 0000000..6c291d4 --- /dev/null +++ b/packages/ui-patterns/tests/ProjectCard.test.tsx @@ -0,0 +1,234 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { SharedUIProvider } from "@microbit/ui"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ReactNode } from "react"; +import { IntlProvider } from "react-intl"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ProjectCard, + ProjectsToolbar, + useProjectActions, + useProjectSelection, +} from "../src"; + +afterEach(cleanup); + +const Providers = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const project = { id: "p1", name: "Heart", timestamp: Date.now() - 120_000 }; + +describe("ProjectCard", () => { + it("shows the name, content, description and age, and opens by name", async () => { + const user = userEvent.setup(); + const onOpen = vi.fn(); + render( + {}} + onRenameDuplicate={() => {}} + > + + , + { wrapper: Providers }, + ); + expect(screen.getByTestId("glyph")).toBeDefined(); + expect(screen.getByText("main.py, helper.py")).toBeDefined(); + expect(screen.getByText("2 minutes ago")).toBeDefined(); + await user.click(screen.getByRole("button", { name: "Heart" })); + expect(onOpen).toHaveBeenCalledWith("p1"); + }); + + it("offers open, rename, duplicate and delete from its menu", async () => { + const user = userEvent.setup(); + const onOpen = vi.fn(); + const onDelete = vi.fn(); + const onRenameDuplicate = vi.fn(); + const setFinalFocusRef = vi.fn(); + render( + , + { wrapper: Providers }, + ); + const menuButton = screen.getByRole("button", { + name: "Heart actions menu", + }); + await user.click(menuButton); + await user.click(screen.getByRole("menuitem", { name: "Duplicate" })); + expect(onRenameDuplicate).toHaveBeenCalledWith("duplicate", "p1"); + expect(setFinalFocusRef).toHaveBeenCalledWith( + expect.objectContaining({ current: menuButton }), + ); + + await user.click(menuButton); + await user.click(screen.getByRole("menuitem", { name: "Delete" })); + expect(onDelete).toHaveBeenCalledWith("p1"); + + await user.click(menuButton); + await user.click(screen.getByRole("menuitem", { name: "Open" })); + expect(onOpen).toHaveBeenCalledWith("p1"); + }); + + it("shows a selection checkbox and skip link only when selectable", async () => { + const user = userEvent.setup(); + const onSelected = vi.fn(); + const onSkipToToolbar = vi.fn(); + const { rerender } = render( + {}} + onDelete={() => {}} + onRenameDuplicate={() => {}} + />, + { wrapper: Providers }, + ); + expect(screen.queryByRole("checkbox")).toBeNull(); + expect( + screen.queryByRole("button", { name: "Skip to toolbar" }), + ).toBeNull(); + + rerender( + {}} + onDelete={() => {}} + onRenameDuplicate={() => {}} + />, + ); + await user.click(screen.getByRole("checkbox", { name: "Select Heart" })); + expect(onSelected).toHaveBeenCalledWith("p1"); + await user.click(screen.getByRole("button", { name: "Skip to toolbar" })); + expect(onSkipToToolbar).toHaveBeenCalledOnce(); + }); +}); + +const projects = [ + { id: "a", name: "Alpha", timestamp: 1 }, + { id: "b", name: "Beta", timestamp: 2 }, +]; + +const Harness = ({ + onRename, + onDuplicate, + onDelete, +}: { + onRename: (id: string, name: string) => void; + onDuplicate: (id: string, name: string) => void; + onDelete: (ids: string[]) => void; +}) => { + const selection = useProjectSelection(projects); + const actions = useProjectActions({ + projects, + onRename, + onDuplicate, + onDelete, + getSelectedIds: () => selection.selectedIds, + }); + return ( + <> + {actions.dialogs} + {selection.hasSelection && ( + + )} + {projects.map((p) => ( + {}} + onDelete={actions.requestDelete} + onRenameDuplicate={actions.renameOrDuplicate} + setFinalFocusRef={actions.setFinalFocusRef} + /> + ))} + + ); +}; + +describe("useProjectActions", () => { + it("renames from a card menu via the name dialog", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(); + render( + , + { + wrapper: Providers, + }, + ); + await user.click( + screen.getByRole("button", { name: "Alpha actions menu" }), + ); + await user.click(screen.getByRole("menuitem", { name: "Rename" })); + const dialog = within(screen.getByRole("dialog")); + expect( + dialog.getByRole("heading", { name: "Rename project" }), + ).toBeDefined(); + const field = dialog.getByRole("textbox", { name: /Name/ }); + expect((field as HTMLInputElement).value).toEqual("Alpha"); + await user.clear(field); + await user.type(field, "Alpha 2"); + await user.click(dialog.getByRole("button", { name: "Rename" })); + expect(onRename).toHaveBeenCalledWith("a", "Alpha 2"); + }); + + it("deletes the selection from the toolbar after confirmation", async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + render( + , + { + wrapper: Providers, + }, + ); + await user.click(screen.getByRole("checkbox", { name: "Select Alpha" })); + await user.click(screen.getByRole("checkbox", { name: "Select Beta" })); + await user.click(screen.getByRole("button", { name: "Delete 2 projects" })); + const alert = within(screen.getByRole("alertdialog")); + expect( + alert.getByText("Are you sure you want to delete 2 projects?"), + ).toBeDefined(); + await user.click(alert.getByRole("button", { name: "Delete 2 projects" })); + expect(onDelete).toHaveBeenCalledWith(["a", "b"]); + }); + + it("deletes one project from its menu, naming it in the confirmation", async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + render( + , + { + wrapper: Providers, + }, + ); + await user.click(screen.getByRole("button", { name: "Beta actions menu" })); + await user.click(screen.getByRole("menuitem", { name: "Delete" })); + const alert = within(screen.getByRole("alertdialog")); + expect(alert.getByText(/delete the project "Beta"/)).toBeDefined(); + await user.click(alert.getByRole("button", { name: "Delete" })); + expect(onDelete).toHaveBeenCalledWith(["b"]); + }); +}); diff --git a/packages/ui-patterns/tests/project-controls.test.tsx b/packages/ui-patterns/tests/project-controls.test.tsx new file mode 100644 index 0000000..383440a --- /dev/null +++ b/packages/ui-patterns/tests/project-controls.test.tsx @@ -0,0 +1,146 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { SharedUIProvider } from "@microbit/ui"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ReactNode } from "react"; +import { IntlProvider } from "react-intl"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProjectsToolbar, SearchInput, SortInput } from "../src"; + +afterEach(cleanup); + +const Providers = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe("SearchInput", () => { + it("reports typing and clears back to the box", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { rerender } = render(, { + wrapper: Providers, + }); + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + await user.type(screen.getByRole("textbox", { name: "Search" }), "h"); + expect(onChange).toHaveBeenLastCalledWith("h"); + + rerender(); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onChange).toHaveBeenLastCalledWith(""); + expect(document.activeElement).toBe( + screen.getByRole("textbox", { name: "Search" }), + ); + }); +}); + +describe("SortInput", () => { + it("offers name and last modified with a direction toggle", async () => { + const user = userEvent.setup(); + const onFieldChange = vi.fn(); + const onToggleDirection = vi.fn(); + render( + , + { wrapper: Providers }, + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Sort projects" }), + "name", + ); + expect(onFieldChange).toHaveBeenCalledWith("name"); + await user.click(screen.getByRole("button", { name: "Descending order" })); + expect(onToggleDirection).toHaveBeenCalledOnce(); + }); + + it("shows relevance and disables itself while searching", () => { + render( + {}} + onToggleDirection={() => {}} + hasSearchQuery + />, + { wrapper: Providers }, + ); + const select = screen.getByRole("combobox", { + name: "Sort projects", + }); + expect(select.disabled).toBe(true); + expect(select.value).toEqual("relevance"); + expect( + screen.getByRole("button", { + name: "Descending order", + }).disabled, + ).toBe(true); + }); +}); + +describe("ProjectsToolbar", () => { + it("offers rename and duplicate only for a single selection", () => { + const { rerender } = render( + {}} + onDelete={() => {}} + onClearSelection={() => {}} + />, + { wrapper: Providers }, + ); + const group = screen.getByRole("group", { name: "Selection actions" }); + expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([ + "Rename", + "Duplicate", + "Delete", + "Clear", + ]); + expect(group).toBeDefined(); + + rerender( + {}} + onDelete={() => {}} + onClearSelection={() => {}} + />, + ); + expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([ + "Delete 3 projects", + "Clear", + ]); + }); + + it("calls back with the reason and keeps accessible names when icon-only", async () => { + const user = userEvent.setup(); + const onRenameDuplicate = vi.fn(); + const onDelete = vi.fn(); + const onClearSelection = vi.fn(); + render( + , + { wrapper: Providers }, + ); + await user.click(screen.getByRole("button", { name: "Duplicate" })); + expect(onRenameDuplicate).toHaveBeenCalledWith("duplicate"); + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(onDelete).toHaveBeenCalledOnce(); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onClearSelection).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui-patterns/tests/project-dialogs.test.tsx b/packages/ui-patterns/tests/project-dialogs.test.tsx new file mode 100644 index 0000000..726bbf1 --- /dev/null +++ b/packages/ui-patterns/tests/project-dialogs.test.tsx @@ -0,0 +1,139 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { SharedUIProvider } from "@microbit/ui"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ReactNode } from "react"; +import { IntlProvider } from "react-intl"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConfirmDialog, NameProjectDialog } from "../src"; + +afterEach(cleanup); + +const Providers = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const dialog = () => within(screen.getByRole("dialog")); + +describe("NameProjectDialog", () => { + it("starts with the initial name and saves the trimmed name", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render( + {}} + onSave={onSave} + confirmText="Save" + />, + { wrapper: Providers }, + ); + expect( + dialog().getByRole("heading", { name: "Name your project" }), + ).toBeDefined(); + const field = dialog().getByRole("textbox", { name: /Name/ }); + expect((field as HTMLInputElement).value).toEqual("Old name"); + await user.clear(field); + await user.type(field, " New name "); + await user.click(dialog().getByRole("button", { name: "Save" })); + expect(onSave).toHaveBeenCalledWith("New name"); + }); + + it("submits on Enter", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render( + {}} + onSave={onSave} + confirmText="Save" + />, + { wrapper: Providers }, + ); + await user.type(dialog().getByRole("textbox", { name: /Name/ }), "{Enter}"); + expect(onSave).toHaveBeenCalledWith("Project"); + }); + + it("refuses a blank name", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render( + {}} + onSave={onSave} + confirmText="Save" + />, + { wrapper: Providers }, + ); + const field = dialog().getByRole("textbox", { name: /Name/ }); + await user.clear(field); + await user.type(field, " "); + expect( + dialog().getByRole("button", { name: "Save" }) + .disabled, + ).toBe(true); + await user.type(field, "{Enter}"); + expect(onSave).not.toHaveBeenCalled(); + expect( + dialog().getByText("The project name cannot be empty"), + ).toBeDefined(); + }); + + it("takes a heading, confirm text and helper text", () => { + render( + {}} + onSave={() => {}} + heading="Rename project" + confirmText="Rename" + helperText="Used when you save." + />, + { wrapper: Providers }, + ); + expect( + dialog().getByRole("heading", { name: "Rename project" }), + ).toBeDefined(); + expect(dialog().getByRole("button", { name: "Rename" })).toBeDefined(); + expect(dialog().getByText("Used when you save.")).toBeDefined(); + }); +}); + +describe("ConfirmDialog", () => { + it("is an alert dialog with cancel focused and a default cancel label", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + render( + , + { wrapper: Providers }, + ); + const alert = within(screen.getByRole("alertdialog")); + expect(alert.getByRole("heading", { name: "Delete it?" })).toBeDefined(); + const cancel = alert.getByRole("button", { name: "Cancel" }); + expect(document.activeElement).toBe(cancel); + await user.click(alert.getByRole("button", { name: "Delete" })); + expect(onConfirm).toHaveBeenCalledOnce(); + await user.click(cancel); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui-patterns/tests/project-list.test.ts b/packages/ui-patterns/tests/project-list.test.ts new file mode 100644 index 0000000..3db7396 --- /dev/null +++ b/packages/ui-patterns/tests/project-list.test.ts @@ -0,0 +1,136 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + defaultSortDirection, + rankProjects, + sortProjects, + useProjectSelection, +} from "../src"; + +interface P { + id: string; + name: string; + timestamp: number; + files: string[]; +} +const p = ( + id: string, + name: string, + timestamp: number, + files: string[] = [], +) => ({ + id, + name, + timestamp, + files, +}); +const files = (project: P) => project.files; + +describe("rankProjects", () => { + const projects = [ + p("a", "Heart", 1, ["main.py"]), + p("b", "Heartbeat monitor", 2, ["main.py", "sensor.py"]), + p("c", "Sensor log", 3, ["heart.py"]), + p("d", "Radio", 4), + ]; + + it("returns the projects unchanged for an empty query", () => { + expect(rankProjects(projects, " ")).toBe(projects); + }); + + it("requires every term to match the name or a secondary term", () => { + expect( + rankProjects(projects, "heart sensor", files).map((x) => x.id), + ).toEqual(["b", "c"]); + }); + + it("ranks exact, prefix then substring name matches above secondary matches", () => { + expect(rankProjects(projects, "heart", files).map((x) => x.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("drops projects with no match and ignores case", () => { + expect(rankProjects(projects, "RADIO", files).map((x) => x.id)).toEqual([ + "d", + ]); + }); +}); + +describe("sortProjects", () => { + const projects = [ + p("a", "banana", 2), + p("b", "Apple", 3), + p("c", "cherry", 1), + ]; + + it("sorts by name ignoring case", () => { + expect(sortProjects(projects, "name", "asc").map((x) => x.id)).toEqual([ + "b", + "a", + "c", + ]); + expect(sortProjects(projects, "name", "desc").map((x) => x.id)).toEqual([ + "c", + "a", + "b", + ]); + }); + + it("sorts by timestamp", () => { + expect( + sortProjects(projects, "timestamp", "desc").map((x) => x.id), + ).toEqual(["b", "a", "c"]); + }); + + it("does not mutate the input", () => { + const copy = [...projects]; + sortProjects(projects, "name", "asc"); + expect(projects).toEqual(copy); + }); + + it("defaults to newest first and A to Z", () => { + expect(defaultSortDirection("timestamp")).toEqual("desc"); + expect(defaultSortDirection("name")).toEqual("asc"); + }); +}); + +describe("useProjectSelection", () => { + it("toggles, clears and remembers the last non-empty selection", () => { + const projects = [p("a", "A", 1), p("b", "B", 2)]; + const { result } = renderHook(() => useProjectSelection(projects)); + expect(result.current.hasSelection).toBe(false); + + act(() => result.current.toggle("a")); + act(() => result.current.toggle("b")); + expect(result.current.selectedIds).toEqual(["a", "b"]); + expect(result.current.isSelected("a")).toBe(true); + + act(() => result.current.toggle("a")); + expect(result.current.selectedIds).toEqual(["b"]); + + act(() => result.current.clear()); + expect(result.current.selectedIds).toEqual([]); + expect(result.current.hasSelection).toBe(false); + expect(result.current.lastSelectedIds).toEqual(["b"]); + }); + + it("drops a selected project that leaves the list", () => { + let projects = [p("a", "A", 1), p("b", "B", 2)]; + const { result, rerender } = renderHook(() => + useProjectSelection(projects), + ); + act(() => result.current.toggle("a")); + act(() => result.current.toggle("b")); + projects = [p("b", "B", 2)]; + rerender(); + expect(result.current.selectedIds).toEqual(["b"]); + }); +}); diff --git a/packages/ui-patterns/tests/time-ago.test.ts b/packages/ui-patterns/tests/time-ago.test.ts new file mode 100644 index 0000000..909ba32 --- /dev/null +++ b/packages/ui-patterns/tests/time-ago.test.ts @@ -0,0 +1,34 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { createIntl } from "react-intl"; +import { describe, expect, it } from "vitest"; +import { formatTimeAgo } from "../src"; + +const intl = createIntl({ locale: "en" }); +const now = 1_700_000_000_000; + +describe("formatTimeAgo", () => { + it("says now within the same second", () => { + expect(formatTimeAgo(intl, now, now)).toEqual("now"); + }); + + it("does not count seconds", () => { + expect(formatTimeAgo(intl, now - 20_000, now)).toEqual("a few seconds ago"); + }); + + it("uses the largest whole unit", () => { + expect(formatTimeAgo(intl, now - 3 * 60_000, now)).toEqual("3 minutes ago"); + expect(formatTimeAgo(intl, now - 2 * 3_600_000, now)).toEqual( + "2 hours ago", + ); + expect(formatTimeAgo(intl, now - 5 * 86_400_000, now)).toEqual( + "5 days ago", + ); + expect(formatTimeAgo(intl, now - 2 * 2_592_000_000, now)).toEqual( + "2 months ago", + ); + }); +}); From adbd780ae2fabb994b74cb4db3db1e220cc93a43 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 11 Sep 2026 22:27:48 +0000 Subject: [PATCH 2/3] Move ConfirmDialog into @microbit/ui A confirm dialog is a standard overlay, not a project thing: ml-trainer uses it for settings and data samples as well, and the Python Editor has its own. It now sits beside Modal, with the Cancel string, and shows under Overlays in Storybook. ui-patterns uses it from there. The peer ranges of ui-patterns and ui-carousel move to the next @microbit/ui minor, which this needs. --- packages/ui-carousel/package.json | 2 +- packages/ui-patterns/lang/ui.en.json | 4 -- packages/ui-patterns/package.json | 2 +- .../src/projects/NameProjectDialog.tsx | 5 +-- packages/ui-patterns/src/projects/index.ts | 1 - .../src/projects/useProjectActions.tsx | 3 +- .../tests/project-dialogs.test.tsx | 29 +----------- packages/ui/lang/ui.en.json | 4 ++ .../src/projects => ui/src}/ConfirmDialog.tsx | 17 ++----- packages/ui/src/index.ts | 1 + .../stories/ConfirmDialog.stories.tsx | 5 +-- packages/ui/tests/ConfirmDialog.test.tsx | 44 +++++++++++++++++++ 12 files changed, 61 insertions(+), 56 deletions(-) rename packages/{ui-patterns/src/projects => ui/src}/ConfirmDialog.tsx (84%) rename packages/{ui-patterns => ui}/stories/ConfirmDialog.stories.tsx (89%) create mode 100644 packages/ui/tests/ConfirmDialog.test.tsx diff --git a/packages/ui-carousel/package.json b/packages/ui-carousel/package.json index 60e276c..576fa0e 100644 --- a/packages/ui-carousel/package.json +++ b/packages/ui-carousel/package.json @@ -31,7 +31,7 @@ "typecheck": "npm run panda && tsc --noEmit" }, "peerDependencies": { - "@microbit/ui": "^0.4.0", + "@microbit/ui": "^0.5.0", "@pandacss/dev": "^1.11.4", "react": "^18.3.1", "react-aria": "^3.50.0", diff --git a/packages/ui-patterns/lang/ui.en.json b/packages/ui-patterns/lang/ui.en.json index bdf99d5..ace3739 100644 --- a/packages/ui-patterns/lang/ui.en.json +++ b/packages/ui-patterns/lang/ui.en.json @@ -1,8 +1,4 @@ { - "ui-patterns.cancel-action": { - "defaultMessage": "Cancel", - "description": "Cancel button in dialogs" - }, "ui-patterns.clear-action": { "defaultMessage": "Clear", "description": "Clears a search box or a selection" diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 2428ea1..4c729e0 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -34,7 +34,7 @@ "@formatjs/intl-localematcher": "^0.8.0" }, "peerDependencies": { - "@microbit/ui": "^0.4.0", + "@microbit/ui": "^0.5.0", "@pandacss/dev": "^1.11.4", "react": "^18.3.1", "react-icons": "^4.12.0 || ^5.0.0", diff --git a/packages/ui-patterns/src/projects/NameProjectDialog.tsx b/packages/ui-patterns/src/projects/NameProjectDialog.tsx index 938b5e8..a66b4b8 100644 --- a/packages/ui-patterns/src/projects/NameProjectDialog.tsx +++ b/packages/ui-patterns/src/projects/NameProjectDialog.tsx @@ -21,6 +21,7 @@ import { useState, } from "react"; import { FormattedMessage } from "react-intl"; +import { uiMessage } from "@microbit/ui/messages"; import { uiPatternsMessage } from "../messages"; export interface NameProjectDialogProps { @@ -133,9 +134,7 @@ export const NameProjectDialog = ({