@seekalfred/primitives 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +664 -9
- package/dist/accordion/accordion.d.ts +5 -5
- package/dist/accordion/accordion.js +22 -11
- package/dist/accordion/accordion.js.map +1 -1
- package/dist/action-button/action-button.d.ts +50 -0
- package/dist/action-button/action-button.js +159 -0
- package/dist/action-button/action-button.js.map +1 -0
- package/dist/action-button/index.d.ts +2 -0
- package/dist/action-button/index.js +2 -0
- package/dist/alert/alert.d.ts +16 -2
- package/dist/alert/alert.js +118 -20
- package/dist/alert/alert.js.map +1 -1
- package/dist/alert-dialog/alert-dialog.d.ts +1 -1
- package/dist/alert-dialog/alert-dialog.js +4 -2
- package/dist/alert-dialog/alert-dialog.js.map +1 -1
- package/dist/avatar/avatar.d.ts +7 -1
- package/dist/avatar/avatar.js +51 -18
- package/dist/avatar/avatar.js.map +1 -1
- package/dist/badge/badge.d.ts +10 -3
- package/dist/badge/badge.js +37 -5
- package/dist/badge/badge.js.map +1 -1
- package/dist/breadcrumb/breadcrumb.d.ts +3 -30
- package/dist/breadcrumb/breadcrumb.js +25 -8
- package/dist/breadcrumb/breadcrumb.js.map +1 -1
- package/dist/button/button.d.ts +36 -23
- package/dist/button/button.js +178 -33
- package/dist/button/button.js.map +1 -1
- package/dist/button-group/button-group.d.ts +76 -0
- package/dist/button-group/button-group.js +403 -0
- package/dist/button-group/button-group.js.map +1 -0
- package/dist/button-group/index.d.ts +2 -0
- package/dist/button-group/index.js +2 -0
- package/dist/calendar/calendar.d.ts +3 -3
- package/dist/calendar/calendar.js +263 -34
- package/dist/calendar/calendar.js.map +1 -1
- package/dist/card/card.d.ts +14 -7
- package/dist/card/card.js +32 -14
- package/dist/card/card.js.map +1 -1
- package/dist/checkbox/checkbox.d.ts +2 -9
- package/dist/checkbox/checkbox.js +28 -29
- package/dist/checkbox/checkbox.js.map +1 -1
- package/dist/confirm-morph/confirm-morph.d.ts +85 -0
- package/dist/confirm-morph/confirm-morph.js +652 -0
- package/dist/confirm-morph/confirm-morph.js.map +1 -0
- package/dist/confirm-morph/index.d.ts +2 -0
- package/dist/confirm-morph/index.js +2 -0
- package/dist/copy-button/copy-button.d.ts +32 -0
- package/dist/copy-button/copy-button.js +134 -0
- package/dist/copy-button/copy-button.js.map +1 -0
- package/dist/copy-button/index.d.ts +2 -0
- package/dist/copy-button/index.js +2 -0
- package/dist/date-picker/date-picker.d.ts +51 -0
- package/dist/date-picker/date-picker.js +241 -0
- package/dist/date-picker/date-picker.js.map +1 -0
- package/dist/date-picker/date-range-picker.d.ts +78 -0
- package/dist/date-picker/date-range-picker.js +1015 -0
- package/dist/date-picker/date-range-picker.js.map +1 -0
- package/dist/date-picker/index.d.ts +4 -0
- package/dist/date-picker/index.js +4 -0
- package/dist/date-picker/picker-parts.d.ts +46 -0
- package/dist/date-picker/picker-parts.js +135 -0
- package/dist/date-picker/picker-parts.js.map +1 -0
- package/dist/dialog/dialog.d.ts +12 -16
- package/dist/dialog/dialog.js +45 -14
- package/dist/dialog/dialog.js.map +1 -1
- package/dist/empty-state/empty-state.d.ts +44 -0
- package/dist/empty-state/empty-state.js +83 -0
- package/dist/empty-state/empty-state.js.map +1 -0
- package/dist/empty-state/index.d.ts +2 -0
- package/dist/empty-state/index.js +2 -0
- package/dist/expanding-button-group/expanding-button-group.d.ts +54 -0
- package/dist/expanding-button-group/expanding-button-group.js +350 -0
- package/dist/expanding-button-group/expanding-button-group.js.map +1 -0
- package/dist/expanding-button-group/index.d.ts +2 -0
- package/dist/expanding-button-group/index.js +2 -0
- package/dist/file-drop-zone/file-drop-zone.d.ts +71 -0
- package/dist/file-drop-zone/file-drop-zone.js +116 -0
- package/dist/file-drop-zone/file-drop-zone.js.map +1 -0
- package/dist/file-drop-zone/index.d.ts +2 -0
- package/dist/file-drop-zone/index.js +2 -0
- package/dist/filter-bar/filter-bar.d.ts +121 -0
- package/dist/filter-bar/filter-bar.js +300 -0
- package/dist/filter-bar/filter-bar.js.map +1 -0
- package/dist/filter-bar/index.d.ts +2 -0
- package/dist/filter-bar/index.js +2 -0
- package/dist/floating-button-group/floating-button-group.d.ts +61 -0
- package/dist/floating-button-group/floating-button-group.js +340 -0
- package/dist/floating-button-group/floating-button-group.js.map +1 -0
- package/dist/floating-button-group/index.d.ts +2 -0
- package/dist/floating-button-group/index.js +2 -0
- package/dist/icon-morph/icon-morph.d.ts +55 -0
- package/dist/icon-morph/icon-morph.js +331 -0
- package/dist/icon-morph/icon-morph.js.map +1 -0
- package/dist/icon-morph/index.d.ts +2 -0
- package/dist/icon-morph/index.js +2 -0
- package/dist/index.d.ts +24 -2
- package/dist/index.js +29 -7
- package/dist/input/input.d.ts +23 -1
- package/dist/input/input.js +37 -13
- package/dist/input/input.js.map +1 -1
- package/dist/label/label.d.ts +1 -1
- package/dist/label/label.js +4 -2
- package/dist/label/label.js.map +1 -1
- package/dist/lib/count-to.js +113 -0
- package/dist/lib/count-to.js.map +1 -0
- package/dist/lib/eye-morph.js +62 -0
- package/dist/lib/eye-morph.js.map +1 -0
- package/dist/lib/field-message.js +336 -0
- package/dist/lib/field-message.js.map +1 -0
- package/dist/lib/field-shell.js +81 -0
- package/dist/lib/field-shell.js.map +1 -0
- package/dist/lib/field-surface.js +4 -2
- package/dist/lib/field-surface.js.map +1 -1
- package/dist/lib/flip.js +80 -0
- package/dist/lib/flip.js.map +1 -0
- package/dist/lib/height-follow.js +59 -0
- package/dist/lib/height-follow.js.map +1 -0
- package/dist/lib/icon-swap.js +47 -0
- package/dist/lib/icon-swap.js.map +1 -0
- package/dist/lib/leaving-list.js +57 -0
- package/dist/lib/leaving-list.js.map +1 -0
- package/dist/lib/merge-refs.js +22 -0
- package/dist/lib/merge-refs.js.map +1 -0
- package/dist/lib/morph-text.js +128 -0
- package/dist/lib/morph-text.js.map +1 -0
- package/dist/lib/motion.js +162 -0
- package/dist/lib/motion.js.map +1 -0
- package/dist/lib/pausable-timeout.js +67 -0
- package/dist/lib/pausable-timeout.js.map +1 -0
- package/dist/lib/rolling-value.js +31 -0
- package/dist/lib/rolling-value.js.map +1 -0
- package/dist/lib/spring-progress.js +114 -0
- package/dist/lib/spring-progress.js.map +1 -0
- package/dist/lib/svg-id.js +10 -0
- package/dist/lib/svg-id.js.map +1 -0
- package/dist/lib/swap-text.js +77 -0
- package/dist/lib/swap-text.js.map +1 -0
- package/dist/lib/use-copy-feedback.js +36 -0
- package/dist/lib/use-copy-feedback.js.map +1 -0
- package/dist/menu/menu.d.ts +5 -5
- package/dist/menu/menu.js +10 -6
- package/dist/menu/menu.js.map +1 -1
- package/dist/page-header/index.d.ts +2 -0
- package/dist/page-header/index.js +2 -0
- package/dist/page-header/page-header.d.ts +75 -0
- package/dist/page-header/page-header.js +83 -0
- package/dist/page-header/page-header.js.map +1 -0
- package/dist/password-field/index.d.ts +2 -0
- package/dist/password-field/index.js +2 -0
- package/dist/password-field/password-field.d.ts +37 -0
- package/dist/password-field/password-field.js +101 -0
- package/dist/password-field/password-field.js.map +1 -0
- package/dist/password-strength/index.d.ts +2 -0
- package/dist/password-strength/index.js +2 -0
- package/dist/password-strength/password-strength.d.ts +127 -0
- package/dist/password-strength/password-strength.js +455 -0
- package/dist/password-strength/password-strength.js.map +1 -0
- package/dist/popover/popover.d.ts +10 -2
- package/dist/popover/popover.js +6 -4
- package/dist/popover/popover.js.map +1 -1
- package/dist/progress/progress.d.ts +2 -2
- package/dist/progress/progress.js +50 -7
- package/dist/progress/progress.js.map +1 -1
- package/dist/radio-group/radio-group.d.ts +3 -3
- package/dist/radio-group/radio-group.js +14 -10
- package/dist/radio-group/radio-group.js.map +1 -1
- package/dist/scroll-area/scroll-area.d.ts +17 -6
- package/dist/scroll-area/scroll-area.js +30 -6
- package/dist/scroll-area/scroll-area.js.map +1 -1
- package/dist/search-field/index.d.ts +2 -0
- package/dist/search-field/index.js +2 -0
- package/dist/search-field/search-field.d.ts +26 -0
- package/dist/search-field/search-field.js +155 -0
- package/dist/search-field/search-field.js.map +1 -0
- package/dist/select/select.d.ts +3 -3
- package/dist/select/select.js +85 -24
- package/dist/select/select.js.map +1 -1
- package/dist/separator/separator.d.ts +1 -1
- package/dist/separator/separator.js +4 -2
- package/dist/separator/separator.js.map +1 -1
- package/dist/sheet/sheet.d.ts +1 -1
- package/dist/sheet/sheet.js +6 -3
- package/dist/sheet/sheet.js.map +1 -1
- package/dist/side-nav/index.d.ts +2 -0
- package/dist/side-nav/index.js +2 -0
- package/dist/side-nav/side-nav.d.ts +72 -0
- package/dist/side-nav/side-nav.js +142 -0
- package/dist/side-nav/side-nav.js.map +1 -0
- package/dist/skeleton/skeleton.d.ts +8 -2
- package/dist/skeleton/skeleton.js +9 -3
- package/dist/skeleton/skeleton.js.map +1 -1
- package/dist/slider/slider.d.ts +2 -2
- package/dist/slider/slider.js +64 -8
- package/dist/slider/slider.js.map +1 -1
- package/dist/spinner/spinner.d.ts +1 -1
- package/dist/spinner/spinner.js +4 -2
- package/dist/spinner/spinner.js.map +1 -1
- package/dist/split-button/index.d.ts +2 -0
- package/dist/split-button/index.js +2 -0
- package/dist/split-button/split-button.d.ts +46 -0
- package/dist/split-button/split-button.js +122 -0
- package/dist/split-button/split-button.js.map +1 -0
- package/dist/stat-card/index.d.ts +2 -0
- package/dist/stat-card/index.js +2 -0
- package/dist/stat-card/stat-card.d.ts +81 -0
- package/dist/stat-card/stat-card.js +152 -0
- package/dist/stat-card/stat-card.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/switch/switch.d.ts +2 -2
- package/dist/switch/switch.js +11 -8
- package/dist/switch/switch.js.map +1 -1
- package/dist/table/index.d.ts +2 -2
- package/dist/table/index.js +2 -2
- package/dist/table/table.d.ts +38 -7
- package/dist/table/table.js +114 -12
- package/dist/table/table.js.map +1 -1
- package/dist/tabs/tabs.d.ts +4 -4
- package/dist/tabs/tabs.js +153 -12
- package/dist/tabs/tabs.js.map +1 -1
- package/dist/textarea/textarea.d.ts +24 -1
- package/dist/textarea/textarea.js +27 -3
- package/dist/textarea/textarea.js.map +1 -1
- package/dist/theme-switch/index.d.ts +2 -0
- package/dist/theme-switch/index.js +2 -0
- package/dist/theme-switch/theme-switch.d.ts +37 -0
- package/dist/theme-switch/theme-switch.js +123 -0
- package/dist/theme-switch/theme-switch.js.map +1 -0
- package/dist/time-picker/index.d.ts +2 -0
- package/dist/time-picker/index.js +2 -0
- package/dist/time-picker/time-picker.d.ts +54 -0
- package/dist/time-picker/time-picker.js +201 -0
- package/dist/time-picker/time-picker.js.map +1 -0
- package/dist/toast/toast.d.ts +2 -2
- package/dist/toast/toast.js +61 -39
- package/dist/toast/toast.js.map +1 -1
- package/dist/tooltip/tooltip.d.ts +3 -3
- package/dist/tooltip/tooltip.js +66 -15
- package/dist/tooltip/tooltip.js.map +1 -1
- package/package.json +83 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dialog.js","names":["Dialog","AriaModalOverlay","AriaModal","AriaDialog","AriaHeading","AriaText","AriaButton"],"sources":["../../src/dialog/dialog.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport type { ReactNode } from \"react\";\nimport {\n Button as AriaButton,\n Dialog as AriaDialog,\n Heading as AriaHeading,\n Modal as AriaModal,\n ModalOverlay as AriaModalOverlay,\n Text as AriaText,\n type ModalOverlayProps as AriaModalOverlayProps,\n} from \"react-aria-components\";\n\nimport { cn } from \"../lib/cn.js\";\n\n/**\n * Dialog, ported from the legacy shadcn/Radix component onto React Aria's\n * Modal primitives.\n *\n * FOUR LEGACY PARTS COLLAPSE INTO TWO PROPS. The census (9 importing files,\n * but only **7** that render anything -- header.tsx and PollQuizCreator.tsx\n * import the parts and use none) reads: DialogContent 9, DialogTitle 8,\n * DialogHeader 7, DialogDescription 2. Those four always appear in the same\n * shape, so `title` and `description` are props of the dialog itself.\n *\n * That is not tidying. `title` is REQUIRED, which makes an unnamed dialog\n * impossible to write -- and the legacy shipped one: LiveQAOverlay.tsx\n * renders a DialogContent with no DialogTitle, so its `aria-labelledby`\n * points at an id that matches no element and the dialog is announced with\n * no name.\n *\n * It also fixes the dangling-description defect for free. Radix set\n * `aria-describedby` unconditionally from a `useId`, so seven of the nine\n * legacy dialogs referenced an id that did not exist. React Aria wires it\n * only when a description slot is actually rendered.\n *\n * `DialogPortal` and `DialogOverlay` are not ported: zero external call\n * sites, and React Aria's ModalOverlay does both jobs.\n *\n * NOT PORTED, and deliberately: the enter/exit animation. The legacy leans\n * on tailwindcss-animate's `animate-in` / `zoom-in-95` utilities, which this\n * repo does not ship, and the motion tokens are a separate task. React Aria\n * exposes `data-entering` / `data-exiting` for whoever adds them.\n *\n * Every string in the cva blocks below is a STATIC literal. Tailwind scans\n * source text, so a class assembled at runtime is invisible to it and would\n * simply never be emitted.\n */\nexport const dialogOverlayVariants = cva([\n // Centring by flex, not by `top-1/2 left-1/2 -translate-1/2`. The legacy's\n // translate pair is a bracket value that check:hardcoding rejects, and\n // flex centring needs no transform to undo.\n \"fixed inset-0 z-50 flex items-center justify-center\",\n // The inset that keeps a full-width panel off the viewport edge. The\n // legacy did this with max-w-[calc(100%-2rem)] -- also a bracket value.\n \"p-4\",\n \"bg-scrim\",\n]);\n\nexport const dialogPanelVariants = cva([\n // max-w-128 (32rem), NOT max-w-lg. `--container-lg` does not exist in this\n // repo -- the container scale holds only page-shell widths (max 75rem,\n // wide 90rem) -- so `max-w-lg` compiles to NOTHING and the panel spans the\n // viewport. It emitted no rule at all, tsc and ESLint were both blind to\n // it, and a screenshot is what caught it. 128 spacing units is 32rem,\n // exactly the legacy sm:max-w-lg.\n \"relative w-full max-w-128\",\n \"rounded-xl border-(length:--border-width) border-border p-6\",\n // bg-card, not bg-background: a dialog is a raised surface, and card is\n // the role that already steps up the ink ramp on dark, where a shadow made\n // of ink is invisible. Its contrast pairs are in the contract already.\n \"bg-card text-card-foreground shadow-modal\",\n \"flex flex-col gap-4\",\n // The reset in @seekalfred/styles is OPT-IN, so a consumer may load none:\n // content-box would add the 24px padding to max-w-lg, and an unstyled\n // document renders text in a serif. The Accordion shipped its panel that\n // way and the Alert overflowed by 46px.\n \"box-border font-sans\",\n // React Aria gives the dialog tabindex=-1 and focuses it when nothing\n // inside is autofocused, so it can receive a visible focus ring it does\n // not want.\n \"outline-none\",\n // A dialog taller than the viewport has to scroll, or its footer is\n // unreachable. max-h is on the panel rather than the overlay so the\n // padding above stays visible.\n \"max-h-full overflow-y-auto\",\n]);\n\nexport const dialogTitleVariants = cva([\n // leading-* AFTER text-*: tailwind-merge treats a later text-* as owning\n // line-height and silently strips a leading-* that precedes it.\n \"text-lg leading-(--leading-tight) font-semibold\",\n // The user-agent <h2> ships a 1em block margin. The legacy was inside a\n // Preflight'd app and never met it.\n \"m-0\",\n]);\n\nexport const dialogDescriptionVariants = cva([\n \"text-sm leading-(--leading-normal) text-muted-foreground\",\n // Same: the user-agent <p> block margin.\n \"m-0\",\n]);\n\nexport const dialogFooterVariants = cva([\n // NOT flex-col-reverse. The legacy reversed the footer below `sm`, so a\n // DOM order of Cancel-then-Confirm rendered Confirm-then-Cancel on a\n // phone -- WCAG 1.3.2 Meaningful Sequence, and both alert-dialog call\n // sites hit it. Wrapping keeps narrow viewports working without lying\n // about the order.\n \"flex flex-wrap items-center justify-end gap-2\",\n]);\n\nconst dialogCloseVariants = cva([\n \"absolute end-4 top-4 inline-flex size-6 items-center justify-center\",\n \"rounded-2xs box-border cursor-pointer\",\n \"border-(length:--border-width) border-transparent bg-transparent\",\n \"text-muted-foreground\",\n \"motion-safe:transition-colors motion-safe:duration-(--duration-fast) motion-safe:ease-standard\",\n // The legacy sat at opacity-70 until hover, dimming the only close\n // affordance below its already-marginal contrast. A token that is legible\n // at rest does the same job without that.\n \"data-[hovered]:text-foreground\",\n // outline-solid is load-bearing: outline-none sets --tw-outline-style:none,\n // and the width utility resolves its style from that variable, so a focus\n // rule can apply a width and a colour and paint nothing. Button shipped\n // exactly that.\n \"outline-none\",\n \"data-[focus-visible]:outline-solid\",\n \"data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset)\",\n \"data-[focus-visible]:outline-(--color-ring)\",\n \"[&_svg]:pointer-events-none [&_svg]:size-4\",\n]);\n\n/**\n * A modal dialog.\n *\n * @example\n * <Dialog isOpen={open} onOpenChange={setOpen} title=\"Delete user\"\n * description=\"This cannot be undone.\">\n * <DialogFooter>\n * <Button slot=\"close\" variant=\"ghost\">Cancel</Button>\n * <Button variant=\"destructive\" onPress={remove}>Delete</Button>\n * </DialogFooter>\n * </Dialog>\n */\nexport interface DialogProps\n extends Omit<AriaModalOverlayProps, \"className\" | \"style\" | \"children\"> {\n /**\n * The dialog's heading, and its accessible name.\n *\n * **Required.** The legacy allowed a dialog with no title and one call\n * site shipped that, producing an `aria-labelledby` pointing at nothing.\n * Making it a required prop turns that into something you cannot express.\n */\n title: ReactNode;\n /**\n * Rendered ABOVE the heading, inside the panel.\n *\n * Exists for Sheet's back control. A panel that replaces its own contents\n * rather than stacking a second modal needs a way back, and that control\n * cannot live in `title`: React Aria wires aria-labelledby to the heading,\n * so a button inside it becomes part of the dialog's accessible name --\n * \"Back Campaign details\" instead of \"Campaign details\".\n */\n beforeTitle?: ReactNode;\n /** Optional supporting text, wired to `aria-describedby` when present. */\n description?: ReactNode;\n /** The dialog body. */\n children?: ReactNode;\n /** Extra classes for the panel. Merged last, so they win conflicts. */\n className?: string;\n /**\n * Extra classes for the scrim, merged last.\n *\n * A centred modal never needs this. A Sheet does: it has to replace the\n * overlay's centring with an edge alignment, and it is the only way to\n * do that without a second copy of the whole overlay.\n */\n overlayClassName?: string;\n /**\n * Whether to render the corner close button.\n *\n * `true` by default. An `alertdialog` sets it `false`: a confirmation must\n * be answered, not dismissed silently.\n */\n closeButton?: boolean;\n /**\n * Accessible name for the close button.\n *\n * A prop because the legacy hard-coded the English string `Close` in an\n * `sr-only` span with no way to override it, which makes the component\n * unlocalisable.\n *\n * @default \"Close\"\n */\n closeLabel?: string;\n /** `alertdialog` for a destructive confirmation. @default \"dialog\" */\n role?: \"dialog\" | \"alertdialog\";\n /**\n * Whether clicking outside closes the dialog.\n *\n * `true`, which is NOT React Aria's default. React Aria ships\n * `isDismissable = false` (useOverlay.mjs:26); Radix, which the legacy\n * component wraps, closes on outside click unless told otherwise. Taking\n * the headless default here would have silently changed the behaviour of\n * all seven legacy call sites, and nothing in the suite would have\n * noticed.\n *\n * An `alertdialog` should set this `false`: a confirmation must be\n * answered, not clicked away.\n *\n * @default true\n */\n isDismissable?: boolean;\n}\n\nexport function Dialog({\n title,\n beforeTitle,\n description,\n children,\n className,\n closeButton = true,\n closeLabel = \"Close\",\n role = \"dialog\",\n isDismissable = true,\n overlayClassName,\n ...props\n}: DialogProps) {\n return (\n // ModalOverlay explicitly, not a bare <Modal>. A bare Modal still renders\n // an overlay div, but className lands on the INNER element -- so the\n // scrim would be unstyleable and would paint nothing at all.\n <AriaModalOverlay\n {...props}\n isDismissable={isDismissable}\n data-slot=\"dialog-overlay\"\n className={cn(dialogOverlayVariants(), overlayClassName)}\n >\n <AriaModal className=\"contents\">\n <AriaDialog\n role={role}\n data-slot=\"dialog\"\n className={cn(dialogPanelVariants(), className)}\n >\n {/*\n Above the heading, not inside it. Sheet's back control lives here:\n folding it into `title` would put a button inside the <h2> React\n Aria wires aria-labelledby to, so the dialog's accessible name\n would become \"Back Campaign details\".\n */}\n {beforeTitle}\n {/*\n slot=\"title\" is what React Aria wires aria-labelledby to. A plain\n <h2> here would render identically and leave the dialog unnamed.\n */}\n <AriaHeading slot=\"title\" data-slot=\"dialog-title\" className={dialogTitleVariants()}>\n {title}\n </AriaHeading>\n {description == null ? null : (\n <AriaText\n slot=\"description\"\n data-slot=\"dialog-description\"\n className={dialogDescriptionVariants()}\n >\n {description}\n </AriaText>\n )}\n {children}\n {closeButton ? (\n // slot=\"close\" is React Aria's own dismiss wiring; an onPress\n // that called onOpenChange by hand would not work inside an\n // uncontrolled DialogTrigger.\n <AriaButton slot=\"close\" aria-label={closeLabel} className={dialogCloseVariants()}>\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 16 16\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n >\n <path d=\"m4 4 8 8M12 4l-8 8\" />\n </svg>\n </AriaButton>\n ) : null}\n </AriaDialog>\n </AriaModal>\n </AriaModalOverlay>\n );\n}\n\n/**\n * Actions at the foot of a dialog.\n *\n * @example\n * <DialogFooter>\n * <Button slot=\"close\" variant=\"ghost\">Cancel</Button>\n * <Button variant=\"destructive\" onPress={remove}>Delete</Button>\n * </DialogFooter>\n */\nexport interface DialogFooterProps {\n children?: ReactNode;\n className?: string;\n}\n\nexport function DialogFooter({ children, className }: DialogFooterProps) {\n return (\n <div data-slot=\"dialog-footer\" className={cn(dialogFooterVariants(), className)}>\n {children}\n </div>\n );\n}\n\n/**\n * Pairs a trigger with a dialog and owns the open state.\n *\n * Re-exported from React Aria unchanged. Only 3 of the 9 legacy call sites\n * use a trigger at all -- the rest drive `isOpen` themselves -- so wrapping\n * it would add a layer with nothing to do.\n */\nexport { DialogTrigger } from \"react-aria-components\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiDA,MAAa,wBAAwB,IAAI;CAIvC;CAGA;CACA;AACF,CAAC;AAED,MAAa,sBAAsB,IAAI;CAOrC;CACA;CAIA;CACA;CAKA;CAIA;CAIA;AACF,CAAC;AAED,MAAa,sBAAsB,IAAI,CAGrC,mDAGA,KACF,CAAC;AAED,MAAa,4BAA4B,IAAI,CAC3C,4DAEA,KACF,CAAC;AAED,MAAa,uBAAuB,IAAI,CAMtC,+CACF,CAAC;AAED,MAAM,sBAAsB,IAAI;CAC9B;CACA;CACA;CACA;CACA;CAIA;CAKA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;AAqFD,SAAgBA,SAAO,EACrB,OACA,aACA,aACA,UACA,WACA,cAAc,MACd,aAAa,SACb,OAAO,UACP,gBAAgB,MAChB,kBACA,GAAG,SACW;CACd,OAIE,oBAACC,cAAD;EACE,GAAI;EACW;EACf,aAAU;EACV,WAAW,GAAG,sBAAsB,GAAG,gBAAgB;EAEvD,UAAA,oBAACC,OAAD;GAAW,WAAU;GACnB,UAAA,qBAACC,QAAD;IACQ;IACN,aAAU;IACV,WAAW,GAAG,oBAAoB,GAAG,SAAS;IAHhD,UAAA;KAWG;KAKD,oBAACC,SAAD;MAAa,MAAK;MAAQ,aAAU;MAAe,WAAW,oBAAoB;MAC/E,UAAA;KACU,CAAA;KACZ,eAAe,OAAO,OACrB,oBAACC,MAAD;MACE,MAAK;MACL,aAAU;MACV,WAAW,0BAA0B;MAEpC,UAAA;KACO,CAAA;KAEX;KACA,cAIC,oBAACC,QAAD;MAAY,MAAK;MAAQ,cAAY;MAAY,WAAW,oBAAoB;MAC9E,UAAA,oBAAC,OAAD;OACE,eAAY;OACZ,SAAQ;OACR,MAAK;OACL,QAAO;OACP,aAAY;OACZ,eAAc;OAEd,UAAA,oBAAC,QAAD,EAAM,GAAE,qBAAsB,CAAA;MAC3B,CAAA;KACK,CAAA,IACV;IACM;;EACH,CAAA;CACK,CAAA;AAEtB;AAgBA,SAAgB,aAAa,EAAE,UAAU,aAAgC;CACvE,OACE,oBAAC,OAAD;EAAK,aAAU;EAAgB,WAAW,GAAG,qBAAqB,GAAG,SAAS;EAC3E;CACE,CAAA;AAET"}
|
|
1
|
+
{"version":3,"file":"dialog.js","names":["Dialog","AriaModalOverlay","AriaModal","AriaDialog","AriaHeading","AriaText","AriaButton"],"sources":["../../src/dialog/dialog.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, type ReactNode } from \"react\";\nimport {\n Button as AriaButton,\n Dialog as AriaDialog,\n Heading as AriaHeading,\n Modal as AriaModal,\n ModalOverlay as AriaModalOverlay,\n Text as AriaText,\n type ModalOverlayProps as AriaModalOverlayProps,\n} from \"react-aria-components\";\n\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * Dialog, ported from the legacy shadcn/Radix component onto React Aria's\n * Modal primitives.\n *\n * FOUR LEGACY PARTS COLLAPSE INTO TWO PROPS. The census (9 importing files,\n * but only **7** that render anything -- header.tsx and PollQuizCreator.tsx\n * import the parts and use none) reads: DialogContent 9, DialogTitle 8,\n * DialogHeader 7, DialogDescription 2. Those four always appear in the same\n * shape, so `title` and `description` are props of the dialog itself.\n *\n * That is not tidying. `title` is REQUIRED, which makes an unnamed dialog\n * impossible to write -- and the legacy shipped one: LiveQAOverlay.tsx\n * renders a DialogContent with no DialogTitle, so its `aria-labelledby`\n * points at an id that matches no element and the dialog is announced with\n * no name.\n *\n * It also fixes the dangling-description defect for free. Radix set\n * `aria-describedby` unconditionally from a `useId`, so seven of the nine\n * legacy dialogs referenced an id that did not exist. React Aria wires it\n * only when a description slot is actually rendered.\n *\n * `DialogPortal` and `DialogOverlay` are not ported: zero external call\n * sites, and React Aria's ModalOverlay does both jobs.\n *\n * NOT PORTED, and deliberately: the enter/exit animation. The legacy leans\n * on tailwindcss-animate's `animate-in` / `zoom-in-95` utilities, which this\n * repo does not ship, and the motion tokens are a separate task. React Aria\n * exposes `data-entering` / `data-exiting` for whoever adds them.\n *\n * Every string in the cva blocks below is a STATIC literal. Tailwind scans\n * source text, so a class assembled at runtime is invisible to it and would\n * simply never be emitted.\n */\nexport const dialogOverlayVariants = cva([\n // Centring by flex, not by `top-1/2 left-1/2 -translate-1/2`. The legacy's\n // translate pair is a bracket value that check:hardcoding rejects, and\n // flex centring needs no transform to undo.\n \"fixed inset-0 z-50 flex items-center justify-center\",\n // The inset that keeps a full-width panel off the viewport edge. The\n // legacy did this with max-w-[calc(100%-2rem)] -- also a bracket value.\n \"p-4\",\n \"bg-scrim\",\n]);\n\nexport const dialogPanelVariants = cva([\n // max-w-128 (32rem), NOT max-w-lg. `--container-lg` does not exist in this\n // repo -- the container scale holds only page-shell widths (max 75rem,\n // wide 90rem) -- so `max-w-lg` compiles to NOTHING and the panel spans the\n // viewport. It emitted no rule at all, tsc and ESLint were both blind to\n // it, and a screenshot is what caught it. 128 spacing units is 32rem,\n // exactly the legacy sm:max-w-lg.\n \"relative w-full max-w-128\",\n \"rounded-xl border-(length:--border-width) border-border p-6\",\n // bg-card, not bg-background: a dialog is a raised surface, and card is\n // the role that already steps up the ink ramp on dark, where a shadow made\n // of ink is invisible. Its contrast pairs are in the contract already.\n \"bg-card text-card-foreground shadow-modal\",\n \"flex flex-col gap-4\",\n // The reset in @seekalfred/styles is OPT-IN, so a consumer may load none:\n // content-box would add the 24px padding to max-w-lg, and an unstyled\n // document renders text in a serif. The Accordion shipped its panel that\n // way and the Alert overflowed by 46px.\n \"box-border font-sans\",\n // React Aria gives the dialog tabindex=-1 and focuses it when nothing\n // inside is autofocused, so it can receive a visible focus ring it does\n // not want.\n \"outline-none\",\n // A dialog taller than the viewport has to scroll, or its footer is\n // unreachable. max-h is on the panel rather than the overlay so the\n // padding above stays visible.\n \"max-h-full overflow-y-auto\",\n]);\n\nexport const dialogTitleVariants = cva([\n // leading-* AFTER text-*: tailwind-merge treats a later text-* as owning\n // line-height and silently strips a leading-* that precedes it.\n \"text-lg leading-(--leading-tight) font-semibold\",\n // The user-agent <h2> ships a 1em block margin. The legacy was inside a\n // Preflight'd app and never met it.\n \"m-0\",\n]);\n\nexport const dialogDescriptionVariants = cva([\n \"text-sm leading-(--leading-normal) text-muted-foreground\",\n // Same: the user-agent <p> block margin.\n \"m-0\",\n]);\n\nexport const dialogFooterVariants = cva([\n // NOT flex-col-reverse. The legacy reversed the footer below `sm`, so a\n // DOM order of Cancel-then-Confirm rendered Confirm-then-Cancel on a\n // phone -- WCAG 1.3.2 Meaningful Sequence, and both alert-dialog call\n // sites hit it. Wrapping keeps narrow viewports working without lying\n // about the order.\n \"flex flex-wrap items-center justify-end gap-2\",\n]);\n\nconst dialogCloseVariants = cva([\n \"absolute end-4 top-4 inline-flex size-6 items-center justify-center\",\n \"rounded-2xs box-border cursor-pointer\",\n \"border-(length:--border-width) border-transparent bg-transparent\",\n \"text-muted-foreground\",\n // Fill on hover, a quick press to .96 and a spring release (see calendar-nav-motion).\n \"calendar-nav-motion\",\n // The legacy sat at opacity-70 until hover, dimming the only close\n // affordance below its already-marginal contrast. A token that is legible\n // at rest does the same job without that.\n \"data-[hovered]:bg-muted data-[hovered]:text-foreground\",\n // outline-solid is load-bearing: outline-none sets --tw-outline-style:none,\n // and the width utility resolves its style from that variable, so a focus\n // rule can apply a width and a colour and paint nothing. Button shipped\n // exactly that.\n \"outline-none\",\n \"data-[focus-visible]:outline-solid\",\n \"data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset)\",\n \"data-[focus-visible]:outline-(--color-ring)\",\n \"[&_svg]:pointer-events-none [&_svg]:size-4\",\n]);\n\n/**\n * A modal dialog.\n *\n * @example\n * <Dialog isOpen={open} onOpenChange={setOpen} title=\"Delete user\"\n * description=\"This cannot be undone.\">\n * <DialogFooter>\n * <Button slot=\"close\" variant=\"ghost\">Cancel</Button>\n * <Button variant=\"destructive\" onPress={remove}>Delete</Button>\n * </DialogFooter>\n * </Dialog>\n */\n/** A string title or description swaps as a whole when its text changes while the dialog is open. */\nfunction Swappable({ value, children }: { value: ReactNode; children: ReactNode }) {\n if (typeof value !== \"string\") return <>{children}</>;\n return (\n <SwapText swapKey={value} className=\"block\">\n {children}\n </SwapText>\n );\n}\n\nexport interface DialogProps\n extends Omit<AriaModalOverlayProps, \"className\" | \"style\" | \"children\"> {\n /**\n * The dialog's heading, and its accessible name.\n *\n * **Required.** The legacy allowed a dialog with no title and one call\n * site shipped that, producing an `aria-labelledby` pointing at nothing.\n * Making it a required prop turns that into something you cannot express.\n */\n title: ReactNode;\n /**\n * Rendered ABOVE the heading, inside the panel.\n *\n * Exists for Sheet's back control. A panel that replaces its own contents\n * rather than stacking a second modal needs a way back, and that control\n * cannot live in `title`: React Aria wires aria-labelledby to the heading,\n * so a button inside it becomes part of the dialog's accessible name --\n * \"Back Campaign details\" instead of \"Campaign details\".\n */\n beforeTitle?: ReactNode;\n /** Optional supporting text, wired to `aria-describedby` when present. */\n description?: ReactNode;\n /** The dialog body. */\n children?: ReactNode;\n /** Extra classes for the panel. Merged last, so they win conflicts. */\n className?: string;\n /**\n * Extra classes for the scrim, merged last.\n *\n * A centred modal never needs this. A Sheet does: it has to replace the\n * overlay's centring with an edge alignment, and it is the only way to\n * do that without a second copy of the whole overlay.\n */\n overlayClassName?: string;\n /**\n * Whether to render the corner close button.\n *\n * `true` by default. An `alertdialog` sets it `false`: a confirmation must\n * be answered, not dismissed silently.\n */\n closeButton?: boolean;\n /**\n * Accessible name for the close button.\n *\n * A prop because the legacy hard-coded the English string `Close` in an\n * `sr-only` span with no way to override it, which makes the component\n * unlocalisable.\n *\n * @default \"Close\"\n */\n closeLabel?: string;\n /** `alertdialog` for a destructive confirmation. @default \"dialog\" */\n role?: \"dialog\" | \"alertdialog\";\n /**\n * Whether clicking outside closes the dialog.\n *\n * `true`, which is NOT React Aria's default. React Aria ships\n * `isDismissable = false` (useOverlay.mjs:26); Radix, which the legacy\n * component wraps, closes on outside click unless told otherwise. Taking\n * the headless default here would have silently changed the behaviour of\n * all seven legacy call sites, and nothing in the suite would have\n * noticed.\n *\n * An `alertdialog` should set this `false`: a confirmation must be\n * answered, not clicked away.\n *\n * @default true\n */\n isDismissable?: boolean;\n /**\n * How the dialog comes in: `rise` fades the overlay (blurring the page) and lifts\n * the panel in on a spring; `none` leaves the entrance to the caller, as an\n * edge-anchored preset (Sheet) does.\n *\n * @default \"rise\"\n */\n entrance?: \"rise\" | \"none\";\n}\n\nexport const Dialog = forwardRef<HTMLElement, DialogProps>(function Dialog(\n {\n title,\n beforeTitle,\n description,\n children,\n className,\n closeButton = true,\n closeLabel = \"Close\",\n role = \"dialog\",\n isDismissable = true,\n overlayClassName,\n entrance = \"rise\",\n ...props\n },\n ref,\n) {\n return (\n // ModalOverlay explicitly, not a bare <Modal>. A bare Modal still renders\n // an overlay div, but className lands on the INNER element -- so the\n // scrim would be unstyleable and would paint nothing at all.\n <AriaModalOverlay\n {...props}\n isDismissable={isDismissable}\n data-slot=\"dialog-overlay\"\n className={cnAll(\n dialogOverlayVariants(),\n entrance === \"rise\" && \"dialog-overlay-motion\",\n overlayClassName,\n )}\n >\n <AriaModal className=\"contents\">\n <AriaDialog\n // Modal is a transparent pass-through (className=\"contents\", no\n // box of its own) and ModalOverlay is the scrim -- AriaDialog is\n // the only one of the three that is the visible panel, so the ref\n // goes here and not on either ancestor.\n ref={ref}\n role={role}\n data-slot=\"dialog\"\n className={cnAll(dialogPanelVariants(), entrance === \"rise\" && \"dialog-panel-motion\", className)}\n >\n {/*\n Above the heading, not inside it. Sheet's back control lives here:\n folding it into `title` would put a button inside the <h2> React\n Aria wires aria-labelledby to, so the dialog's accessible name\n would become \"Back Campaign details\".\n */}\n {beforeTitle}\n {/*\n slot=\"title\" is what React Aria wires aria-labelledby to. A plain\n <h2> here would render identically and leave the dialog unnamed.\n */}\n <AriaHeading slot=\"title\" data-slot=\"dialog-title\" className={dialogTitleVariants()}>\n <Swappable value={title}>{title}</Swappable>\n </AriaHeading>\n {description == null ? null : (\n <AriaText\n slot=\"description\"\n data-slot=\"dialog-description\"\n className={dialogDescriptionVariants()}\n >\n <Swappable value={description}>{description}</Swappable>\n </AriaText>\n )}\n {children}\n {closeButton ? (\n // slot=\"close\" is React Aria's own dismiss wiring; an onPress\n // that called onOpenChange by hand would not work inside an\n // uncontrolled DialogTrigger.\n <AriaButton slot=\"close\" aria-label={closeLabel} className={dialogCloseVariants()}>\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 16 16\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n >\n <path d=\"m4 4 8 8M12 4l-8 8\" />\n </svg>\n </AriaButton>\n ) : null}\n </AriaDialog>\n </AriaModal>\n </AriaModalOverlay>\n );\n});\n\n/**\n * Actions at the foot of a dialog.\n *\n * @example\n * <DialogFooter>\n * <Button slot=\"close\" variant=\"ghost\">Cancel</Button>\n * <Button variant=\"destructive\" onPress={remove}>Delete</Button>\n * </DialogFooter>\n */\nexport interface DialogFooterProps {\n children?: ReactNode;\n className?: string;\n}\n\nexport const DialogFooter = forwardRef<HTMLDivElement, DialogFooterProps>(function DialogFooter(\n { children, className },\n ref,\n) {\n return (\n <div ref={ref} data-slot=\"dialog-footer\" className={cn(dialogFooterVariants(), className)}>\n {children}\n </div>\n );\n});\n\n/**\n * Pairs a trigger with a dialog and owns the open state.\n *\n * Re-exported from React Aria unchanged. Only 3 of the 9 legacy call sites\n * use a trigger at all -- the rest drive `isOpen` themselves -- so wrapping\n * it would add a layer with nothing to do.\n */\nexport { DialogTrigger } from \"react-aria-components\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkDA,MAAa,wBAAwB,IAAI;CAIvC;CAGA;CACA;AACF,CAAC;AAED,MAAa,sBAAsB,IAAI;CAOrC;CACA;CAIA;CACA;CAKA;CAIA;CAIA;AACF,CAAC;AAED,MAAa,sBAAsB,IAAI,CAGrC,mDAGA,KACF,CAAC;AAED,MAAa,4BAA4B,IAAI,CAC3C,4DAEA,KACF,CAAC;AAED,MAAa,uBAAuB,IAAI,CAMtC,+CACF,CAAC;AAED,MAAM,sBAAsB,IAAI;CAC9B;CACA;CACA;CACA;CAEA;CAIA;CAKA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;;;;;;;;AAeD,SAAS,UAAU,EAAE,OAAO,YAAuD;CACjF,IAAI,OAAO,UAAU,UAAU,OAAO,oBAAA,YAAA,EAAG,SAAW,CAAA;CACpD,OACE,oBAAC,UAAD;EAAU,SAAS;EAAO,WAAU;EACjC;CACO,CAAA;AAEd;AAiFA,MAAaA,WAAS,WAAqC,SAASA,SAClE,EACE,OACA,aACA,aACA,UACA,WACA,cAAc,MACd,aAAa,SACb,OAAO,UACP,gBAAgB,MAChB,kBACA,WAAW,QACX,GAAG,SAEL,KACA;CACA,OAIE,oBAACC,cAAD;EACE,GAAI;EACW;EACf,aAAU;EACV,WAAW,MACT,sBAAsB,GACtB,aAAa,UAAU,yBACvB,gBACF;EAEA,UAAA,oBAACC,OAAD;GAAW,WAAU;GACnB,UAAA,qBAACC,QAAD;IAKO;IACC;IACN,aAAU;IACV,WAAW,MAAM,oBAAoB,GAAG,aAAa,UAAU,uBAAuB,SAAS;IARjG,UAAA;KAgBG;KAKD,oBAACC,SAAD;MAAa,MAAK;MAAQ,aAAU;MAAe,WAAW,oBAAoB;MAChF,UAAA,oBAAC,WAAD;OAAW,OAAO;OAAQ,UAAA;MAAiB,CAAA;KAChC,CAAA;KACZ,eAAe,OAAO,OACrB,oBAACC,MAAD;MACE,MAAK;MACL,aAAU;MACV,WAAW,0BAA0B;MAErC,UAAA,oBAAC,WAAD;OAAW,OAAO;OAAc,UAAA;MAAuB,CAAA;KAC/C,CAAA;KAEX;KACA,cAIC,oBAACC,QAAD;MAAY,MAAK;MAAQ,cAAY;MAAY,WAAW,oBAAoB;MAC9E,UAAA,oBAAC,OAAD;OACE,eAAY;OACZ,SAAQ;OACR,MAAK;OACL,QAAO;OACP,aAAY;OACZ,eAAc;OAEd,UAAA,oBAAC,QAAD,EAAM,GAAE,qBAAsB,CAAA;MAC3B,CAAA;KACK,CAAA,IACV;IACM;;EACH,CAAA;CACK,CAAA;AAEtB,CAAC;AAgBD,MAAa,eAAe,WAA8C,SAAS,aACjF,EAAE,UAAU,aACZ,KACA;CACA,OACE,oBAAC,OAAD;EAAU;EAAK,aAAU;EAAgB,WAAW,GAAG,qBAAqB,GAAG,SAAS;EACrF;CACE,CAAA;AAET,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
//#region src/empty-state/empty-state.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* EmptyState: what a list shows when it has nothing to show. One line of
|
|
5
|
+
* muted text, centred in a bordered box the width of the list, with an
|
|
6
|
+
* optional next step under it.
|
|
7
|
+
*
|
|
8
|
+
* Presentational, so there is no React Aria base. It exists because every
|
|
9
|
+
* list has an empty case, and without a shared component each screen picks its
|
|
10
|
+
* own padding, border and alignment, so "nothing here" looks different on
|
|
11
|
+
* every page.
|
|
12
|
+
*
|
|
13
|
+
* Every string in the cva blocks below is a STATIC literal. Tailwind scans
|
|
14
|
+
* source text, so a class assembled at runtime is invisible to it and would
|
|
15
|
+
* simply never be emitted.
|
|
16
|
+
*/
|
|
17
|
+
export declare const emptyStateVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
|
|
18
|
+
export declare const emptyStateTextVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
|
|
19
|
+
export declare const emptyStateActionVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
|
|
20
|
+
/**
|
|
21
|
+
* An empty list's message.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <EmptyState action={<Button size="sm">Clear filters</Button>}>
|
|
25
|
+
* Nothing matches these filters.
|
|
26
|
+
* </EmptyState>
|
|
27
|
+
*/
|
|
28
|
+
export interface EmptyStateProps extends ComponentPropsWithoutRef<"div"> {
|
|
29
|
+
/** Why the list is empty, in one short statement. */
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
/**
|
|
32
|
+
* A glyph above the message. It settles in once when the state first appears
|
|
33
|
+
* (small and transparent to rest over 480ms); a different icon component later
|
|
34
|
+
* swaps in place, and the same one re-rendering stays still. Mark it `aria-hidden`.
|
|
35
|
+
*/
|
|
36
|
+
icon?: ReactNode;
|
|
37
|
+
/** A next step under the text: a button or a link. Omitted from the DOM when absent. */
|
|
38
|
+
action?: ReactNode;
|
|
39
|
+
/** Extra classes for the box. Merged last, so they win conflicts with ours. */
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare const EmptyState: import("react").ForwardRefExoticComponent<EmptyStateProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
43
|
+
//#endregion
|
|
44
|
+
//# sourceMappingURL=empty-state.d.ts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { SwapText } from "../lib/swap-text.js";
|
|
4
|
+
import { useHeightFollow } from "../lib/height-follow.js";
|
|
5
|
+
import { cva } from "class-variance-authority";
|
|
6
|
+
import { forwardRef, isValidElement, useRef } from "react";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/empty-state/empty-state.tsx
|
|
9
|
+
/**
|
|
10
|
+
* EmptyState: what a list shows when it has nothing to show. One line of
|
|
11
|
+
* muted text, centred in a bordered box the width of the list, with an
|
|
12
|
+
* optional next step under it.
|
|
13
|
+
*
|
|
14
|
+
* Presentational, so there is no React Aria base. It exists because every
|
|
15
|
+
* list has an empty case, and without a shared component each screen picks its
|
|
16
|
+
* own padding, border and alignment, so "nothing here" looks different on
|
|
17
|
+
* every page.
|
|
18
|
+
*
|
|
19
|
+
* Every string in the cva blocks below is a STATIC literal. Tailwind scans
|
|
20
|
+
* source text, so a class assembled at runtime is invisible to it and would
|
|
21
|
+
* simply never be emitted.
|
|
22
|
+
*/
|
|
23
|
+
const emptyStateVariants = cva([
|
|
24
|
+
"block px-4 py-8 text-center",
|
|
25
|
+
"rounded-md border-(length:--border-width) border-solid border-border",
|
|
26
|
+
"box-border font-sans"
|
|
27
|
+
]);
|
|
28
|
+
const emptyStateTextVariants = cva("m-0 text-sm leading-(--leading-normal) text-muted-foreground");
|
|
29
|
+
const emptyStateActionVariants = cva("mt-3 flex justify-center gap-2");
|
|
30
|
+
/** The name an icon swaps under: a different component swaps; the same one re-rendering does not. */
|
|
31
|
+
function iconKey(icon) {
|
|
32
|
+
if (!isValidElement(icon)) return "icon";
|
|
33
|
+
const type = icon.type;
|
|
34
|
+
return typeof type === "string" ? type : type.displayName ?? type.name ?? "icon";
|
|
35
|
+
}
|
|
36
|
+
const EmptyState = forwardRef(function EmptyState({ children, icon, action, className, ...props }, ref) {
|
|
37
|
+
const frame = useRef(null);
|
|
38
|
+
const text = useRef(null);
|
|
39
|
+
useHeightFollow(frame, text, typeof children === "string" ? children : "");
|
|
40
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
41
|
+
ref,
|
|
42
|
+
...props,
|
|
43
|
+
"data-slot": "empty-state",
|
|
44
|
+
className: cn(emptyStateVariants(), className),
|
|
45
|
+
children: [
|
|
46
|
+
icon === void 0 || icon === null ? null : /* @__PURE__ */ jsx("div", {
|
|
47
|
+
"data-slot": "empty-state-icon",
|
|
48
|
+
"aria-hidden": "true",
|
|
49
|
+
className: "mx-auto mb-3 grid size-10 place-items-center rounded-md border-(length:--border-width) border-border bg-muted text-muted-foreground motion-safe:animate-empty-state-icon [&_svg]:size-5",
|
|
50
|
+
children: /* @__PURE__ */ jsx(SwapText, {
|
|
51
|
+
swapKey: iconKey(icon),
|
|
52
|
+
motion: "glyph",
|
|
53
|
+
className: "grid place-items-center",
|
|
54
|
+
children: icon
|
|
55
|
+
})
|
|
56
|
+
}),
|
|
57
|
+
/* @__PURE__ */ jsx("div", {
|
|
58
|
+
ref: frame,
|
|
59
|
+
"data-slot": "empty-state-copy",
|
|
60
|
+
className: "height-follow",
|
|
61
|
+
children: /* @__PURE__ */ jsx("p", {
|
|
62
|
+
ref: text,
|
|
63
|
+
"data-slot": "empty-state-text",
|
|
64
|
+
className: emptyStateTextVariants(),
|
|
65
|
+
children: typeof children === "string" ? /* @__PURE__ */ jsx(SwapText, {
|
|
66
|
+
swapKey: children,
|
|
67
|
+
className: "block",
|
|
68
|
+
children
|
|
69
|
+
}) : children
|
|
70
|
+
})
|
|
71
|
+
}),
|
|
72
|
+
action == null ? null : /* @__PURE__ */ jsx("div", {
|
|
73
|
+
"data-slot": "empty-state-action",
|
|
74
|
+
className: emptyStateActionVariants(),
|
|
75
|
+
children: action
|
|
76
|
+
})
|
|
77
|
+
]
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
//#endregion
|
|
81
|
+
export { EmptyState, emptyStateActionVariants, emptyStateTextVariants, emptyStateVariants };
|
|
82
|
+
|
|
83
|
+
//# sourceMappingURL=empty-state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"empty-state.js","names":[],"sources":["../../src/empty-state/empty-state.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport {\n forwardRef,\n isValidElement,\n useRef,\n type ComponentPropsWithoutRef,\n type ReactNode,\n} from \"react\";\n\nimport { cn } from \"../lib/cn.js\";\nimport { useHeightFollow } from \"../lib/height-follow.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * EmptyState: what a list shows when it has nothing to show. One line of\n * muted text, centred in a bordered box the width of the list, with an\n * optional next step under it.\n *\n * Presentational, so there is no React Aria base. It exists because every\n * list has an empty case, and without a shared component each screen picks its\n * own padding, border and alignment, so \"nothing here\" looks different on\n * every page.\n *\n * Every string in the cva blocks below is a STATIC literal. Tailwind scans\n * source text, so a class assembled at runtime is invisible to it and would\n * simply never be emitted.\n */\nexport const emptyStateVariants = cva([\n \"block px-4 py-8 text-center\",\n \"rounded-md border-(length:--border-width) border-solid border-border\",\n // The reset in @seekalfred/styles is opt-in, so a consumer may load none.\n \"box-border font-sans\",\n]);\n\nexport const emptyStateTextVariants = cva(\n \"m-0 text-sm leading-(--leading-normal) text-muted-foreground\",\n);\n\nexport const emptyStateActionVariants = cva(\"mt-3 flex justify-center gap-2\");\n\n/**\n * An empty list's message.\n *\n * @example\n * <EmptyState action={<Button size=\"sm\">Clear filters</Button>}>\n * Nothing matches these filters.\n * </EmptyState>\n */\nexport interface EmptyStateProps extends ComponentPropsWithoutRef<\"div\"> {\n /** Why the list is empty, in one short statement. */\n children: ReactNode;\n /**\n * A glyph above the message. It settles in once when the state first appears\n * (small and transparent to rest over 480ms); a different icon component later\n * swaps in place, and the same one re-rendering stays still. Mark it `aria-hidden`.\n */\n icon?: ReactNode;\n /** A next step under the text: a button or a link. Omitted from the DOM when absent. */\n action?: ReactNode;\n /** Extra classes for the box. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\n/** The name an icon swaps under: a different component swaps; the same one re-rendering does not. */\nfunction iconKey(icon: ReactNode) {\n if (!isValidElement(icon)) return \"icon\";\n const type = icon.type as string | { displayName?: string; name?: string };\n return typeof type === \"string\" ? type : (type.displayName ?? type.name ?? \"icon\");\n}\n\nexport const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(\n { children, icon, action, className, ...props },\n ref,\n) {\n const frame = useRef<HTMLDivElement>(null);\n const text = useRef<HTMLParagraphElement>(null);\n // The message column follows its height when the message changes.\n useHeightFollow(frame, text, typeof children === \"string\" ? children : \"\");\n return (\n <div\n ref={ref}\n {...props}\n data-slot=\"empty-state\"\n className={cn(emptyStateVariants(), className)}\n >\n {icon === undefined || icon === null ? null : (\n <div\n data-slot=\"empty-state-icon\"\n aria-hidden=\"true\"\n className=\"mx-auto mb-3 grid size-10 place-items-center rounded-md border-(length:--border-width) border-border bg-muted text-muted-foreground motion-safe:animate-empty-state-icon [&_svg]:size-5\"\n >\n <SwapText swapKey={iconKey(icon)} motion=\"glyph\" className=\"grid place-items-center\">\n {icon}\n </SwapText>\n </div>\n )}\n <div ref={frame} data-slot=\"empty-state-copy\" className=\"height-follow\">\n <p ref={text} data-slot=\"empty-state-text\" className={emptyStateTextVariants()}>\n {typeof children === \"string\" ? (\n <SwapText swapKey={children} className=\"block\">\n {children}\n </SwapText>\n ) : (\n children\n )}\n </p>\n </div>\n {action == null ? null : (\n <div data-slot=\"empty-state-action\" className={emptyStateActionVariants()}>\n {action}\n </div>\n )}\n </div>\n );\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,qBAAqB,IAAI;CACpC;CACA;CAEA;AACF,CAAC;AAED,MAAa,yBAAyB,IACpC,8DACF;AAEA,MAAa,2BAA2B,IAAI,gCAAgC;;AA0B5E,SAAS,QAAQ,MAAiB;CAChC,IAAI,CAAC,eAAe,IAAI,GAAG,OAAO;CAClC,MAAM,OAAO,KAAK;CAClB,OAAO,OAAO,SAAS,WAAW,OAAQ,KAAK,eAAe,KAAK,QAAQ;AAC7E;AAEA,MAAa,aAAa,WAA4C,SAAS,WAC7E,EAAE,UAAU,MAAM,QAAQ,WAAW,GAAG,SACxC,KACA;CACA,MAAM,QAAQ,OAAuB,IAAI;CACzC,MAAM,OAAO,OAA6B,IAAI;CAE9C,gBAAgB,OAAO,MAAM,OAAO,aAAa,WAAW,WAAW,EAAE;CACzE,OACE,qBAAC,OAAD;EACO;EACL,GAAI;EACJ,aAAU;EACV,WAAW,GAAG,mBAAmB,GAAG,SAAS;EAJ/C,UAAA;GAMG,SAAS,KAAA,KAAa,SAAS,OAAO,OACrC,oBAAC,OAAD;IACE,aAAU;IACV,eAAY;IACZ,WAAU;IAEV,UAAA,oBAAC,UAAD;KAAU,SAAS,QAAQ,IAAI;KAAG,QAAO;KAAQ,WAAU;KACxD,UAAA;IACO,CAAA;GACP,CAAA;GAEP,oBAAC,OAAD;IAAK,KAAK;IAAO,aAAU;IAAmB,WAAU;IACtD,UAAA,oBAAC,KAAD;KAAG,KAAK;KAAM,aAAU;KAAmB,WAAW,uBAAuB;KAC1E,UAAA,OAAO,aAAa,WACnB,oBAAC,UAAD;MAAU,SAAS;MAAU,WAAU;MACpC;KACO,CAAA,IAEV;IAED,CAAA;GACA,CAAA;GACJ,UAAU,OAAO,OAChB,oBAAC,OAAD;IAAK,aAAU;IAAqB,WAAW,yBAAyB;IACrE,UAAA;GACE,CAAA;EAEJ;;AAET,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/expanding-button-group/expanding-button-group.d.ts
|
|
3
|
+
export type ExpandingButtonGroupSize = "sm" | "md";
|
|
4
|
+
export type ExpandingButtonGroupTone = "neutral" | "danger";
|
|
5
|
+
export interface ExpandingButtonGroupItem {
|
|
6
|
+
/** Stable key for the action. Also the value passed to onAction. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Visible label once the button expands, and its accessible name at all times. Use a short verb, such as "Archive". */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Icon shown while collapsed. A 16px icon fits; it is sized by the group. Mark it `aria-hidden`. */
|
|
11
|
+
icon: ReactNode;
|
|
12
|
+
/** Runs the action. Return false, or reject, to skip the confirmation. A promise keeps the button busy until it settles. */
|
|
13
|
+
onSelect?: () => void | boolean | Promise<void | boolean>;
|
|
14
|
+
/** Past tense word shown in place after the action, such as "Archived". The icon becomes a check for confirmDuration. Omit for actions that open something. */
|
|
15
|
+
doneLabel?: string;
|
|
16
|
+
/** `danger` colours the expanded state with the destructive token, for destructive actions such as Delete. */
|
|
17
|
+
tone?: ExpandingButtonGroupTone;
|
|
18
|
+
/** Dimmed and inert. Stays focusable and still reveals its label, so people can learn what it would do. */
|
|
19
|
+
isDisabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface ExpandingButtonGroupProps {
|
|
22
|
+
items: readonly ExpandingButtonGroupItem[];
|
|
23
|
+
/** Accessible name of the toolbar, such as "Message actions". */
|
|
24
|
+
label: string;
|
|
25
|
+
/** @default "md" */
|
|
26
|
+
size?: ExpandingButtonGroupSize;
|
|
27
|
+
/**
|
|
28
|
+
* Id of the action whose label shows at rest, usually the primary one. Defaults to the first enabled action: one action is always
|
|
29
|
+
* expanded. Pointing at or focusing another moves the expansion there, and it returns here when the pointer and focus leave.
|
|
30
|
+
*/
|
|
31
|
+
defaultExpanded?: string | null;
|
|
32
|
+
/** Called with the item id whenever an action runs, before its own onSelect. */
|
|
33
|
+
onAction?: (id: string) => void;
|
|
34
|
+
/** How long the confirmation stays, in milliseconds. @default 1400 */
|
|
35
|
+
confirmDuration?: number;
|
|
36
|
+
/** Extra classes. Merged last, so they win conflicts with ours. */
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A compact pill of icon buttons for actions. One action always shows its label;
|
|
41
|
+
* the one you point at or focus takes over the label while its neighbours slide
|
|
42
|
+
* aside, and an action can confirm in place: the icon draws a check and the label
|
|
43
|
+
* becomes a past tense word. On touch, the first tap reveals the label and a
|
|
44
|
+
* second tap runs the action.
|
|
45
|
+
*
|
|
46
|
+
* Arrow keys move focus, Home and End jump to the ends. A disabled action stays
|
|
47
|
+
* focusable and still reveals its label, so people can learn what it would do.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* <ExpandingButtonGroup label="Message actions" items={[{ id: "archive", label: "Archive", doneLabel: "Archived", icon: <ArchiveIcon aria-hidden /> }]} />
|
|
51
|
+
*/
|
|
52
|
+
export declare function ExpandingButtonGroup({ items, label, size, defaultExpanded, onAction, confirmDuration, className }: ExpandingButtonGroupProps): import("react").JSX.Element;
|
|
53
|
+
//#endregion
|
|
54
|
+
//# sourceMappingURL=expanding-button-group.d.ts.map
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { useExitBackstop, useIsomorphicLayoutEffect, useLeavingItems } from "../lib/motion.js";
|
|
4
|
+
import { IconSwap } from "../lib/icon-swap.js";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/expanding-button-group/expanding-button-group.tsx
|
|
8
|
+
/** Geometry per size. The icon keeps the same left inset collapsed and expanded, so it never moves while the label is revealed. */
|
|
9
|
+
const metrics = {
|
|
10
|
+
sm: {
|
|
11
|
+
height: 28,
|
|
12
|
+
icon: 14,
|
|
13
|
+
start: 7,
|
|
14
|
+
gap: 5,
|
|
15
|
+
end: 11
|
|
16
|
+
},
|
|
17
|
+
md: {
|
|
18
|
+
height: 36,
|
|
19
|
+
icon: 16,
|
|
20
|
+
start: 10,
|
|
21
|
+
gap: 7,
|
|
22
|
+
end: 14
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** Pixels the pointer must travel into a neighbouring action before the expansion moves to it. */
|
|
26
|
+
const hysteresis = 4;
|
|
27
|
+
/** The confirmation tick draws itself from its short stroke. */
|
|
28
|
+
function DrawnCheck() {
|
|
29
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
30
|
+
viewBox: "0 0 24 24",
|
|
31
|
+
fill: "none",
|
|
32
|
+
stroke: "currentColor",
|
|
33
|
+
strokeWidth: 2,
|
|
34
|
+
strokeLinecap: "round",
|
|
35
|
+
strokeLinejoin: "round",
|
|
36
|
+
"aria-hidden": "true",
|
|
37
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
38
|
+
d: "M4 12.5l5 5L20 6.5",
|
|
39
|
+
pathLength: 1,
|
|
40
|
+
className: "[stroke-dasharray:1] motion-safe:animate-check-draw-quick"
|
|
41
|
+
})
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function LeavingWord({ id, onDone, children }) {
|
|
45
|
+
const remove = useCallback(() => onDone(id), [onDone, id]);
|
|
46
|
+
useExitBackstop(true, remove);
|
|
47
|
+
return /* @__PURE__ */ jsx("span", {
|
|
48
|
+
"aria-hidden": "true",
|
|
49
|
+
onAnimationEnd: (event) => {
|
|
50
|
+
if (event.target === event.currentTarget) remove();
|
|
51
|
+
},
|
|
52
|
+
className: "col-start-1 row-start-1 whitespace-nowrap motion-safe:animate-word-out motion-reduce:animate-fade-out",
|
|
53
|
+
children
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function Item({ item, size, expanded, done, busy, tabStop, onPointerDown, onClick, onFocus }) {
|
|
57
|
+
const m = metrics[size];
|
|
58
|
+
const labelRef = useRef(null);
|
|
59
|
+
const doneRef = useRef(null);
|
|
60
|
+
const wordRef = useRef(null);
|
|
61
|
+
const [text, setText] = useState(0);
|
|
62
|
+
useIsomorphicLayoutEffect(() => {
|
|
63
|
+
const measure = () => {
|
|
64
|
+
const next = Math.max(labelRef.current?.getBoundingClientRect().width ?? 0, doneRef.current?.getBoundingClientRect().width ?? 0);
|
|
65
|
+
setText((current) => Math.abs(current - next) < .5 ? current : next);
|
|
66
|
+
};
|
|
67
|
+
measure();
|
|
68
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
69
|
+
let frame = 0;
|
|
70
|
+
const observer = new ResizeObserver(() => {
|
|
71
|
+
cancelAnimationFrame(frame);
|
|
72
|
+
frame = requestAnimationFrame(measure);
|
|
73
|
+
});
|
|
74
|
+
if (labelRef.current) observer.observe(labelRef.current);
|
|
75
|
+
if (doneRef.current) observer.observe(doneRef.current);
|
|
76
|
+
return () => {
|
|
77
|
+
cancelAnimationFrame(frame);
|
|
78
|
+
observer.disconnect();
|
|
79
|
+
};
|
|
80
|
+
}, [
|
|
81
|
+
item.label,
|
|
82
|
+
item.doneLabel,
|
|
83
|
+
size
|
|
84
|
+
]);
|
|
85
|
+
const showDone = done && !!item.doneLabel;
|
|
86
|
+
const measured = text > 0;
|
|
87
|
+
const target = expanded && measured ? m.start + m.icon + m.gap + text + m.end : m.height;
|
|
88
|
+
const word = showDone ? item.doneLabel : item.label;
|
|
89
|
+
const { leaving, changed, remove } = useLeavingItems(word, word, () => wordRef.current);
|
|
90
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
91
|
+
type: "button",
|
|
92
|
+
style: { width: target },
|
|
93
|
+
"data-ebg-item": "",
|
|
94
|
+
"data-id": item.id,
|
|
95
|
+
"data-tone": item.tone ?? "neutral",
|
|
96
|
+
"data-state": showDone ? "done" : "idle",
|
|
97
|
+
"data-expanded": expanded || void 0,
|
|
98
|
+
"data-unmeasured": measured ? void 0 : "",
|
|
99
|
+
"aria-label": item.label,
|
|
100
|
+
"aria-disabled": item.isDisabled || void 0,
|
|
101
|
+
"aria-busy": busy || void 0,
|
|
102
|
+
tabIndex: tabStop ? 0 : -1,
|
|
103
|
+
onPointerDown: (event) => onPointerDown(item, event),
|
|
104
|
+
onClick: () => onClick(item),
|
|
105
|
+
onFocus: (event) => onFocus(item, event),
|
|
106
|
+
className: "expanding-group-item focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset) focus-visible:outline-(--color-ring)",
|
|
107
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
108
|
+
className: "expanding-group-icon",
|
|
109
|
+
"aria-hidden": "true",
|
|
110
|
+
children: /* @__PURE__ */ jsx(IconSwap, {
|
|
111
|
+
iconKey: showDone ? "done" : "icon",
|
|
112
|
+
tight: true,
|
|
113
|
+
fill: true,
|
|
114
|
+
className: "size-full",
|
|
115
|
+
children: showDone ? /* @__PURE__ */ jsx(DrawnCheck, {}) : item.icon
|
|
116
|
+
})
|
|
117
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
118
|
+
className: "expanding-group-label",
|
|
119
|
+
"aria-hidden": "true",
|
|
120
|
+
children: [
|
|
121
|
+
leaving.map((entry) => /* @__PURE__ */ jsx(LeavingWord, {
|
|
122
|
+
id: entry.id,
|
|
123
|
+
onDone: remove,
|
|
124
|
+
children: entry.value
|
|
125
|
+
}, entry.id)),
|
|
126
|
+
/* @__PURE__ */ jsx("span", {
|
|
127
|
+
ref: wordRef,
|
|
128
|
+
className: cn("col-start-1 row-start-1 whitespace-nowrap" + (changed ? " motion-safe:animate-word-in motion-reduce:animate-fade-in" : "")),
|
|
129
|
+
children: word
|
|
130
|
+
}, word),
|
|
131
|
+
/* @__PURE__ */ jsx("span", {
|
|
132
|
+
ref: labelRef,
|
|
133
|
+
className: "pointer-events-none invisible col-start-1 row-start-1 whitespace-nowrap",
|
|
134
|
+
children: item.label
|
|
135
|
+
}),
|
|
136
|
+
item.doneLabel ? /* @__PURE__ */ jsx("span", {
|
|
137
|
+
ref: doneRef,
|
|
138
|
+
className: "pointer-events-none invisible col-start-1 row-start-1 whitespace-nowrap",
|
|
139
|
+
children: item.doneLabel
|
|
140
|
+
}) : null
|
|
141
|
+
]
|
|
142
|
+
})]
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A compact pill of icon buttons for actions. One action always shows its label;
|
|
147
|
+
* the one you point at or focus takes over the label while its neighbours slide
|
|
148
|
+
* aside, and an action can confirm in place: the icon draws a check and the label
|
|
149
|
+
* becomes a past tense word. On touch, the first tap reveals the label and a
|
|
150
|
+
* second tap runs the action.
|
|
151
|
+
*
|
|
152
|
+
* Arrow keys move focus, Home and End jump to the ends. A disabled action stays
|
|
153
|
+
* focusable and still reveals its label, so people can learn what it would do.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* <ExpandingButtonGroup label="Message actions" items={[{ id: "archive", label: "Archive", doneLabel: "Archived", icon: <ArchiveIcon aria-hidden /> }]} />
|
|
157
|
+
*/
|
|
158
|
+
function ExpandingButtonGroup({ items, label, size = "md", defaultExpanded = null, onAction, confirmDuration = 1400, className }) {
|
|
159
|
+
const root = useRef(null);
|
|
160
|
+
const [intent, setIntent] = useState(null);
|
|
161
|
+
const [done, setDone] = useState(null);
|
|
162
|
+
const [busy, setBusy] = useState(null);
|
|
163
|
+
const [announcement, setAnnouncement] = useState("");
|
|
164
|
+
const [focused, setFocused] = useState(null);
|
|
165
|
+
const [settled, setSettled] = useState(false);
|
|
166
|
+
const pointerOver = useRef(null);
|
|
167
|
+
const pointerAt = useRef(null);
|
|
168
|
+
const keyFocus = useRef(null);
|
|
169
|
+
const touchOpen = useRef(null);
|
|
170
|
+
const press = useRef(null);
|
|
171
|
+
const doneRef = useRef(null);
|
|
172
|
+
const timer = useRef(null);
|
|
173
|
+
const mounted = useRef(true);
|
|
174
|
+
const seq = useRef(0);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
mounted.current = true;
|
|
177
|
+
const frames = requestAnimationFrame(() => requestAnimationFrame(() => setSettled(true)));
|
|
178
|
+
return () => {
|
|
179
|
+
mounted.current = false;
|
|
180
|
+
cancelAnimationFrame(frames);
|
|
181
|
+
if (timer.current) clearTimeout(timer.current);
|
|
182
|
+
};
|
|
183
|
+
}, []);
|
|
184
|
+
const has = (id) => !!id && items.some((item) => item.id === id);
|
|
185
|
+
const firstEnabled = items.find((item) => !item.isDisabled)?.id ?? items[0]?.id ?? null;
|
|
186
|
+
const restId = has(defaultExpanded) ? defaultExpanded : firstEnabled;
|
|
187
|
+
const expandedId = has(intent) ? intent : restId;
|
|
188
|
+
const tabStop = has(focused) ? focused : restId;
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!intent || touchOpen.current !== intent) return;
|
|
191
|
+
const onDown = (event) => {
|
|
192
|
+
if (root.current?.contains(event.target)) return;
|
|
193
|
+
touchOpen.current = null;
|
|
194
|
+
setIntent(doneRef.current ?? keyFocus.current);
|
|
195
|
+
};
|
|
196
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
197
|
+
return () => document.removeEventListener("pointerdown", onDown, true);
|
|
198
|
+
}, [intent]);
|
|
199
|
+
const confirm = useCallback((item) => {
|
|
200
|
+
if (timer.current) clearTimeout(timer.current);
|
|
201
|
+
seq.current += 1;
|
|
202
|
+
doneRef.current = item.id;
|
|
203
|
+
setDone({
|
|
204
|
+
id: item.id,
|
|
205
|
+
seq: seq.current
|
|
206
|
+
});
|
|
207
|
+
setAnnouncement((item.doneLabel ?? "") + (seq.current % 2 ? "" : ""));
|
|
208
|
+
timer.current = setTimeout(() => {
|
|
209
|
+
doneRef.current = null;
|
|
210
|
+
setDone(null);
|
|
211
|
+
if (pointerOver.current !== item.id && keyFocus.current !== item.id) {
|
|
212
|
+
if (touchOpen.current === item.id) touchOpen.current = null;
|
|
213
|
+
setIntent((current) => current === item.id ? pointerOver.current ?? keyFocus.current : current);
|
|
214
|
+
}
|
|
215
|
+
}, confirmDuration);
|
|
216
|
+
}, [confirmDuration]);
|
|
217
|
+
const activate = useCallback(async (item) => {
|
|
218
|
+
if (item.isDisabled || busy === item.id || doneRef.current === item.id) return;
|
|
219
|
+
setIntent(item.id);
|
|
220
|
+
onAction?.(item.id);
|
|
221
|
+
let result;
|
|
222
|
+
try {
|
|
223
|
+
const pending = item.onSelect?.();
|
|
224
|
+
if (pending instanceof Promise) {
|
|
225
|
+
setBusy(item.id);
|
|
226
|
+
result = await pending;
|
|
227
|
+
} else result = pending;
|
|
228
|
+
} catch {
|
|
229
|
+
result = false;
|
|
230
|
+
} finally {
|
|
231
|
+
if (mounted.current) setBusy((current) => current === item.id ? null : current);
|
|
232
|
+
}
|
|
233
|
+
if (!mounted.current || result === false || !item.doneLabel) return;
|
|
234
|
+
confirm(item);
|
|
235
|
+
}, [
|
|
236
|
+
busy,
|
|
237
|
+
confirm,
|
|
238
|
+
onAction
|
|
239
|
+
]);
|
|
240
|
+
const onItemPointerDown = (item, event) => {
|
|
241
|
+
press.current = {
|
|
242
|
+
id: item.id,
|
|
243
|
+
kind: event.pointerType,
|
|
244
|
+
wasExpanded: expandedId === item.id
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
const onItemClick = (item) => {
|
|
248
|
+
const last = press.current;
|
|
249
|
+
press.current = null;
|
|
250
|
+
if (last && last.id === item.id && last.kind === "touch" && !last.wasExpanded) {
|
|
251
|
+
touchOpen.current = item.id;
|
|
252
|
+
setIntent(item.id);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (last?.kind === "touch") touchOpen.current = item.id;
|
|
256
|
+
activate(item);
|
|
257
|
+
};
|
|
258
|
+
const onItemFocus = (item, event) => {
|
|
259
|
+
setFocused(item.id);
|
|
260
|
+
let visible = true;
|
|
261
|
+
try {
|
|
262
|
+
visible = event.currentTarget.matches(":focus-visible");
|
|
263
|
+
} catch {}
|
|
264
|
+
if (!visible) return;
|
|
265
|
+
keyFocus.current = item.id;
|
|
266
|
+
setIntent(item.id);
|
|
267
|
+
};
|
|
268
|
+
const onBlur = (event) => {
|
|
269
|
+
if (root.current?.contains(event.relatedTarget)) return;
|
|
270
|
+
keyFocus.current = null;
|
|
271
|
+
touchOpen.current = null;
|
|
272
|
+
setIntent(pointerOver.current ?? doneRef.current);
|
|
273
|
+
};
|
|
274
|
+
const onPointerMove = (event) => {
|
|
275
|
+
if (event.pointerType !== "mouse") return;
|
|
276
|
+
const at = pointerAt.current;
|
|
277
|
+
if (at && Math.abs(at.x - event.clientX) < 1 && Math.abs(at.y - event.clientY) < 1) return;
|
|
278
|
+
pointerAt.current = {
|
|
279
|
+
x: event.clientX,
|
|
280
|
+
y: event.clientY
|
|
281
|
+
};
|
|
282
|
+
const button = event.target.closest("[data-ebg-item]");
|
|
283
|
+
const id = button?.dataset.id;
|
|
284
|
+
if (!button || !id || id === pointerOver.current) return;
|
|
285
|
+
if (pointerOver.current) {
|
|
286
|
+
const box = button.getBoundingClientRect();
|
|
287
|
+
if (event.clientX < box.left + hysteresis || event.clientX > box.right - hysteresis) return;
|
|
288
|
+
}
|
|
289
|
+
pointerOver.current = id;
|
|
290
|
+
setIntent(id);
|
|
291
|
+
};
|
|
292
|
+
const onPointerLeave = (event) => {
|
|
293
|
+
if (event.pointerType !== "mouse") return;
|
|
294
|
+
pointerAt.current = null;
|
|
295
|
+
pointerOver.current = null;
|
|
296
|
+
setIntent(doneRef.current ?? keyFocus.current);
|
|
297
|
+
};
|
|
298
|
+
const onKeyDown = (event) => {
|
|
299
|
+
const list = Array.from(root.current?.querySelectorAll("[data-ebg-item]") ?? []);
|
|
300
|
+
const index = list.findIndex((button) => button === document.activeElement);
|
|
301
|
+
if (index < 0) return;
|
|
302
|
+
const last = list.length - 1;
|
|
303
|
+
const target = event.key === "ArrowRight" ? index === last ? 0 : index + 1 : event.key === "ArrowLeft" ? index === 0 ? last : index - 1 : event.key === "Home" ? 0 : event.key === "End" ? last : -1;
|
|
304
|
+
if (target < 0) return;
|
|
305
|
+
event.preventDefault();
|
|
306
|
+
list[target].focus();
|
|
307
|
+
};
|
|
308
|
+
const m = metrics[size];
|
|
309
|
+
const vars = {
|
|
310
|
+
"--ebg-h": m.height + "px",
|
|
311
|
+
"--ebg-icon": m.icon + "px",
|
|
312
|
+
"--ebg-start": m.start + "px",
|
|
313
|
+
"--ebg-gap": m.gap + "px",
|
|
314
|
+
"--ebg-end": m.end + "px"
|
|
315
|
+
};
|
|
316
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
317
|
+
ref: root,
|
|
318
|
+
role: "toolbar",
|
|
319
|
+
"aria-label": label,
|
|
320
|
+
"aria-orientation": "horizontal",
|
|
321
|
+
"data-size": size,
|
|
322
|
+
"data-settled": settled ? "" : void 0,
|
|
323
|
+
style: vars,
|
|
324
|
+
onKeyDown,
|
|
325
|
+
onBlur,
|
|
326
|
+
onPointerMove,
|
|
327
|
+
onPointerLeave,
|
|
328
|
+
className: cn("expanding-group", className),
|
|
329
|
+
children: [items.map((item) => /* @__PURE__ */ jsx(Item, {
|
|
330
|
+
item,
|
|
331
|
+
size,
|
|
332
|
+
expanded: expandedId === item.id,
|
|
333
|
+
done: done?.id === item.id,
|
|
334
|
+
busy: busy === item.id,
|
|
335
|
+
tabStop: tabStop === item.id,
|
|
336
|
+
onPointerDown: onItemPointerDown,
|
|
337
|
+
onClick: onItemClick,
|
|
338
|
+
onFocus: onItemFocus
|
|
339
|
+
}, item.id)), /* @__PURE__ */ jsx("span", {
|
|
340
|
+
className: "sr-only",
|
|
341
|
+
role: "status",
|
|
342
|
+
"aria-live": "polite",
|
|
343
|
+
children: announcement
|
|
344
|
+
})]
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
export { ExpandingButtonGroup };
|
|
349
|
+
|
|
350
|
+
//# sourceMappingURL=expanding-button-group.js.map
|