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/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..ace3739 100644 --- a/packages/ui-patterns/lang/ui.en.json +++ b/packages/ui-patterns/lang/ui.en.json @@ -1,4 +1,8 @@ { + "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 +11,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 +79,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 +95,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/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/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/NameProjectDialog.tsx b/packages/ui-patterns/src/projects/NameProjectDialog.tsx new file mode 100644 index 0000000..a66b4b8 --- /dev/null +++ b/packages/ui-patterns/src/projects/NameProjectDialog.tsx @@ -0,0 +1,145 @@ +/** + * (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 { uiMessage } from "@microbit/ui/messages"; +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..872322d --- /dev/null +++ b/packages/ui-patterns/src/projects/ProjectCard.tsx @@ -0,0 +1,295 @@ +/** + * (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, 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 { 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; + /** + * Rename, duplicate and delete come with the menu button that started + * them, so a dialog can return focus there once it closes (the menu has + * gone by then). useProjectActions handles this. + */ + onRename: (id: string, trigger?: HTMLElement) => void; + onDuplicate: (id: string, trigger?: HTMLElement) => void; + onDelete: (id: string, trigger?: HTMLElement) => 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, + onRename, + onDuplicate, + onDelete, + bodyCss, + className, +}: ProjectCardProps) => { + const intl = useIntl(); + const { id, name, timestamp } = project; + const handleOpen = useCallback(() => onOpen(id), [id, onOpen]); + const date = new Date(timestamp); + return ( + + + + + + {children} + + {name} + + {description !== undefined && ( + + {description} + + )} + + + + + + + + ); +}; + +interface ProjectCardActionsProps { + id: string; + name: string; + isSelected?: boolean; + onSelected?: (id: string) => void; + onSkipToToolbar?: () => void; + onOpen: (id: string) => void; + onRename: (id: string, trigger?: HTMLElement) => void; + onDuplicate: (id: string, trigger?: HTMLElement) => void; + onDelete: (id: string, trigger?: HTMLElement) => void; +} + +const ProjectCardActions = ({ + id, + name, + isSelected, + onSelected, + onSkipToToolbar, + onOpen, + onRename, + onDuplicate, + onDelete, +}: ProjectCardActionsProps) => { + const intl = useIntl(); + const menuButtonRef = useRef(null); + const trigger = () => menuButtonRef.current ?? undefined; + 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={() => onRename(id, trigger())} + textValue={intl.formatMessage( + uiPatternsMessage("ui-patterns.rename-project-action"), + )} + > + + + } + onAction={() => onDuplicate(id, trigger())} + textValue={intl.formatMessage( + uiPatternsMessage("ui-patterns.duplicate-project-action"), + )} + > + + + } + onAction={() => onDelete(id, trigger())} + 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..149a61b --- /dev/null +++ b/packages/ui-patterns/src/projects/ProjectsToolbar.tsx @@ -0,0 +1,175 @@ +/** + * (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"; + +export interface ProjectsToolbarProps { + /** How many projects are selected. Rename and duplicate need exactly one. */ + selectedCount: number; + onRename: () => void; + onDuplicate: () => 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, + onRename, + onDuplicate, + 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 ? ( + onRename()} + css={iconCss} + aria-label={intl.formatMessage( + uiPatternsMessage("ui-patterns.rename-project-action"), + )} + > + + + ) : ( + + ))} + {isSingle && + (iconOnly ? ( + onDuplicate()} + css={iconCss} + aria-label={intl.formatMessage( + uiPatternsMessage("ui-patterns.duplicate-project-action"), + )} + > + + + ) : ( + + ))} + {iconOnly && isSingle ? ( + onDelete()} + css={iconCss} + aria-label={intl.formatMessage( + uiPatternsMessage("ui-patterns.delete-project-action"), + { count: selectedCount }, + )} + > + + + ) : ( + + )} + {iconOnly ? ( + onClearSelection()} + css={iconCss} + aria-label={intl.formatMessage( + uiPatternsMessage("ui-patterns.clear-action"), + )} + > + + + ) : ( + + )} + + ); +}; diff --git a/packages/ui-patterns/src/projects/SearchInput.tsx b/packages/ui-patterns/src/projects/SearchInput.tsx new file mode 100644 index 0000000..e2ce006 --- /dev/null +++ b/packages/ui-patterns/src/projects/SearchInput.tsx @@ -0,0 +1,85 @@ +/** + * (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="search" + css={{ + // We draw our own clear button. + appearance: "none", + "&::-webkit-search-cancel-button": { display: "none" }, + 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..ec795f4 --- /dev/null +++ b/packages/ui-patterns/src/projects/index.ts @@ -0,0 +1,14 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +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..c487d05 --- /dev/null +++ b/packages/ui-patterns/src/projects/types.ts @@ -0,0 +1,20 @@ +/** + * (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 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..1af8d50 --- /dev/null +++ b/packages/ui-patterns/src/projects/useProjectActions.tsx @@ -0,0 +1,208 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { ConfirmDialog, Text } from "@microbit/ui"; +import { ReactNode, useCallback, useMemo, useState } from "react"; +import { FormattedMessage } from "react-intl"; +import { uiPatternsMessage } from "../messages"; +import { NameProjectDialog } from "./NameProjectDialog"; +import { ProjectSummary } from "./types"; + +type NameDialogReason = "rename" | "duplicate"; + +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[]; +} + +/** + * Each action takes the id of the project, or acts on the selection without + * one, and optionally the element that started it, which the dialog returns + * focus to on closing. ProjectCard passes its menu button. + */ +type ProjectAction = (id?: string, trigger?: HTMLElement) => void; + +export interface ProjectActions { + /** + * The name and confirm dialogs, wired up. Render once on the page. + */ + dialogs: ReactNode; + /** Opens the name dialog. Needs exactly one project. */ + rename: ProjectAction; + /** Opens the name dialog for the copy. Needs exactly one project. */ + duplicate: ProjectAction; + /** Opens the confirm dialog for one project or the selection. */ + requestDelete: ProjectAction; +} + +/** + * 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 [trigger, setTrigger] = useState(); + const finalFocusRef = useMemo( + () => ({ current: trigger ?? null }), + [trigger], + ); + const clearFinalFocusRef = useCallback(() => setTrigger(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 openNameDialog = useCallback( + (reason: NameDialogReason, id?: string, trigger?: HTMLElement) => { + const project = resolve(id); + if (project) { + setTarget(project); + setTrigger(trigger); + setNameReason(reason); + } + }, + [resolve], + ); + const rename = useCallback( + (id, trigger) => openNameDialog("rename", id, trigger), + [openNameDialog], + ); + const duplicate = useCallback( + (id, trigger) => openNameDialog("duplicate", id, trigger), + [openNameDialog], + ); + 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, trigger) => { + const project = id ? resolve(id) : undefined; + setTarget(project); + setTrigger(trigger); + 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, rename, duplicate, requestDelete }; +}; 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..9841d11 --- /dev/null +++ b/packages/ui-patterns/stories/ProjectCard.stories.tsx @@ -0,0 +1,106 @@ +/** + * (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}`), + onRename: (id) => alert(`rename ${id}`), + onDuplicate: (id) => alert(`duplicate ${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..348dd1a --- /dev/null +++ b/packages/ui-patterns/stories/ProjectsPageExample.stories.tsx @@ -0,0 +1,247 @@ +/** + * (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} + onRename={actions.rename} + onDuplicate={actions.duplicate} + > + + + + ))} + + ) : ( + + 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..dd60518 --- /dev/null +++ b/packages/ui-patterns/stories/ProjectsToolbar.stories.tsx @@ -0,0 +1,49 @@ +/** + * (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, + onRename: () => alert("rename"), + onDuplicate: () => alert("duplicate"), + 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..2f7c800 --- /dev/null +++ b/packages/ui-patterns/tests/ProjectCard.test.tsx @@ -0,0 +1,244 @@ +/** + * (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( + {}} + onRename={() => {}} + onDuplicate={() => {}} + > + + , + { wrapper: Providers }, + ); + expect(screen.getByTestId("glyph")).toBeDefined(); + expect(screen.getByText("main.py, helper.py")).toBeDefined(); + const time = screen.getByText("2 minutes ago"); + expect(time.tagName).toBe("TIME"); + expect(time.getAttribute("dateTime")).toBe( + new Date(project.timestamp).toISOString(), + ); + expect(time.getAttribute("title")).toMatch(/\d/); + await user.click(screen.getByRole("button", { name: "Heart" })); + expect(onOpen).toHaveBeenCalledWith("p1"); + }); + + it("offers open, rename, duplicate and delete from its menu, passing the menu button", async () => { + const user = userEvent.setup(); + const onOpen = vi.fn(); + const onRename = vi.fn(); + const onDuplicate = vi.fn(); + const onDelete = 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(onDuplicate).toHaveBeenCalledWith("p1", menuButton); + + await user.click(menuButton); + await user.click(screen.getByRole("menuitem", { name: "Rename" })); + expect(onRename).toHaveBeenCalledWith("p1", menuButton); + + await user.click(menuButton); + await user.click(screen.getByRole("menuitem", { name: "Delete" })); + expect(onDelete).toHaveBeenCalledWith("p1", menuButton); + + 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={() => {}} + onRename={() => {}} + onDuplicate={() => {}} + />, + { wrapper: Providers }, + ); + expect(screen.queryByRole("checkbox")).toBeNull(); + expect( + screen.queryByRole("button", { name: "Skip to toolbar" }), + ).toBeNull(); + + rerender( + {}} + onDelete={() => {}} + onRename={() => {}} + onDuplicate={() => {}} + />, + ); + 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} + onRename={actions.rename} + onDuplicate={actions.duplicate} + /> + ))} + + ); +}; + +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..d0bef1f --- /dev/null +++ b/packages/ui-patterns/tests/project-controls.test.tsx @@ -0,0 +1,154 @@ +/** + * (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("searchbox", { 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("searchbox", { 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( + {}} + onDuplicate={() => {}} + 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( + {}} + onDuplicate={() => {}} + onDelete={() => {}} + onClearSelection={() => {}} + />, + ); + expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([ + "Delete 3 projects", + "Clear", + ]); + }); + + it("calls back and keeps accessible names when icon-only", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(); + const onDuplicate = vi.fn(); + const onDelete = vi.fn(); + const onClearSelection = vi.fn(); + render( + , + { wrapper: Providers }, + ); + await user.click(screen.getByRole("button", { name: "Duplicate" })); + expect(onDuplicate).toHaveBeenCalledOnce(); + // No arguments: the hook treats a first argument as a project id. + expect(onDuplicate).toHaveBeenCalledWith(); + await user.click(screen.getByRole("button", { name: "Rename" })); + expect(onRename).toHaveBeenCalledWith(); + 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..e0f88d4 --- /dev/null +++ b/packages/ui-patterns/tests/project-dialogs.test.tsx @@ -0,0 +1,112 @@ +/** + * (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 { 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(); + }); +}); 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", + ); + }); +}); diff --git a/packages/ui/lang/ui.en.json b/packages/ui/lang/ui.en.json index aca855f..6ed9561 100644 --- a/packages/ui/lang/ui.en.json +++ b/packages/ui/lang/ui.en.json @@ -3,6 +3,10 @@ "defaultMessage": "Breadcrumb", "description": "Accessible label for the breadcrumb navigation trail (the WAI-ARIA APG's conventional name)" }, + "ui.cancel-action": { + "defaultMessage": "Cancel", + "description": "Cancel button in dialogs" + }, "ui.close-action": { "defaultMessage": "Close", "description": "Close button text or label" diff --git a/packages/ui/src/ConfirmDialog.tsx b/packages/ui/src/ConfirmDialog.tsx new file mode 100644 index 0000000..e2579ce --- /dev/null +++ b/packages/ui/src/ConfirmDialog.tsx @@ -0,0 +1,66 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { ReactNode, RefObject } from "react"; +import { FormattedMessage } from "react-intl"; +import { Button } from "./Button"; +import { uiMessage } from "./messages"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "./Modal"; + +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/src/index.ts b/packages/ui/src/index.ts index ca005a3..f5444a6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -57,6 +57,7 @@ export * from "./ListBox"; export * from "./ComboBox"; export * from "./Menu"; export * from "./Modal"; +export * from "./ConfirmDialog"; export * from "./PopoverArrow"; export * from "./SharedUIProvider"; export * from "./Tooltip"; diff --git a/packages/ui/stories/ConfirmDialog.stories.tsx b/packages/ui/stories/ConfirmDialog.stories.tsx new file mode 100644 index 0000000..a5b1ddf --- /dev/null +++ b/packages/ui/stories/ConfirmDialog.stories.tsx @@ -0,0 +1,52 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box, Button, ConfirmDialog, Text, useDisclosure } from "../src"; + +const meta = { + title: "Overlays/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/tests/ConfirmDialog.test.tsx b/packages/ui/tests/ConfirmDialog.test.tsx new file mode 100644 index 0000000..a9d5690 --- /dev/null +++ b/packages/ui/tests/ConfirmDialog.test.tsx @@ -0,0 +1,44 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +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, expect, it, vi } from "vitest"; +import { ConfirmDialog, SharedUIProvider } from "../src"; + +afterEach(cleanup); + +const Providers = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +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(); +});