@enonic/ui-kit 0.6.0 → 0.7.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  Composite React components for Enonic applications — what
4
4
  [`@enonic/ui`](https://www.npmjs.com/package/@enonic/ui) would be if its parts carried behaviour.
5
5
  The dialog with an outcome and its confirmations are here, the filter input of a browse screen,
6
- and the theme switcher; layouts, toolbars and the rest of the browse screen follow.
6
+ the theme switcher and the toast stack; layouts, toolbars and the rest of the browse screen follow.
7
7
 
8
8
  A component belongs here when it holds state, coordinates several base components, or encodes a
9
9
  screen pattern more than one application repeats. Props in, callbacks out: nothing here reaches
@@ -54,29 +54,31 @@ tells an application which keys its bundle lacks.
54
54
 
55
55
  ## What is here
56
56
 
57
- | Export | What it does |
58
- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
- | `ActionDialog` | `Dialog` from `@enonic/ui` plus an outcome: a `Content` whose own controls take no focus from a field (so validate on confirm, not on blur alone), a `Body` inert while the footer asks, a `Footer` that takes the action or asks |
60
- | `ActionDialog.Footer` | Cancel and Confirm when given `onConfirm`, or the controls it is given as children — a wizard's step indicator; `intent`, `error`, `confirmRef`; `question` asks in place of the controls |
61
- | `ActionDialog.Action` | a further button of the footer, enabled by the content like Confirm; `intent`, `closeOnClick` |
62
- | `useCloseGuard({ dirty, open, onOpenChange })` | the dirty-close question: the mask, the X, `Escape` and Cancel ask before a close that would lose something; `asking` feeds the footer's `question`, `keep` returns the focus |
63
- | `Gate` | `Root`, `Hint` naming what to type back, `Input` that enables the confirm button on a match and locks |
64
- | `DialogPreset.Confirm` | the plain question: title, question, two answers of equal weight |
65
- | `DeleteConfirm` | the delete view: question, targets, the gate asking for the one name or the count, a red button — the first view, or the view after a list, with `closeOnCancel` off to go back to it |
66
- | `deleteExpectation(targets)` | the one name, or the count |
67
- | `matchesExpected(typed, expected)` | the gate's comparison, spaces around the entry forgiven |
68
- | `fillPhrase(phrase, values)` | a phrase with its `{n}` placeholders replaced by nodes |
69
- | `uiKitPhrases`, `useUiKitPhrases()` | the catalogue, and the hook that resolves it through the application's `Translate` |
70
- | `useActionDialog()` | the content's state, for anything inside it that enables the action |
71
- | `FilterInput` | one input for a list's filter: values of predefined `fields` combined with free text, every term a `Tag`; `value`/`onChange` or `defaultValue`; `disabled`, `readOnly` (the tags alone, no input), `error`; the ref is the text input's |
72
- | `FilterField`, `FilterValue` | what the dropdown offers: a field with an id, a label, an icon, its values, `multiple` (on unless `false`) and a `notice`; a value with a `count`, a `group` and `disabled` |
73
- | `FilterQuery`, `FilterTerm` | what the list is narrowed by: `FieldTerm` and `TextTerm`, discriminated by `kind`, ids never labels, in the order they were added; `fieldTerm`, `textTerm`, `termKey` |
74
- | `textOf(query)`, `valuesOf(query, field)` | the two readings a list takes of a query: the free text as one string, and every value picked for a field — empty narrows nothing |
75
- | `withTerm`, `withoutTerm`, `toggledTerm` | the pure operations on a query the input is built from, with `sameTerm`, `isTextTerm`, `isFieldTerm` and `EMPTY_FILTER` |
76
- | `filterInputPhrases` | the input's English, part of `uiKitPhrases` |
77
- | `ThemeSwitcher` | the button that cycles the shared theme — light, dark, system — reading and writing it itself; `className` |
78
- | `useResolvedTheme()` | the theme to paint, `light` or `dark`, re-rendering when the user switches here or in another tab, or the OS does on `system` |
79
- | `themeSwitcherPhrases` | the switcher's English, part of `uiKitPhrases` |
57
+ | Export | What it does |
58
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | `ActionDialog` | `Dialog` from `@enonic/ui` plus an outcome: a `Content` whose own controls take no focus from a field (so validate on confirm, not on blur alone), a `Body` inert while the footer asks, a `Footer` that takes the action or asks |
60
+ | `ActionDialog.Footer` | Cancel and Confirm when given `onConfirm`, or the controls it is given as children — a wizard's step indicator; `intent`, `error`, `confirmRef`; `question` asks in place of the controls |
61
+ | `ActionDialog.Action` | a further button of the footer, enabled by the content like Confirm; `intent`, `closeOnClick` |
62
+ | `useCloseGuard({ dirty, open, onOpenChange })` | the dirty-close question: the mask, the X, `Escape` and Cancel ask before a close that would lose something; `asking` feeds the footer's `question`, `keep` returns the focus |
63
+ | `Gate` | `Root`, `Hint` naming what to type back, `Input` that enables the confirm button on a match and locks |
64
+ | `DialogPreset.Confirm` | the plain question: title, question, two answers of equal weight |
65
+ | `DeleteConfirm` | the delete view: question, targets, the gate asking for the one name or the count, a red button — the first view, or the view after a list, with `closeOnCancel` off to go back to it |
66
+ | `deleteExpectation(targets)` | the one name, or the count |
67
+ | `matchesExpected(typed, expected)` | the gate's comparison, spaces around the entry forgiven |
68
+ | `fillPhrase(phrase, values)` | a phrase with its `{n}` placeholders replaced by nodes |
69
+ | `uiKitPhrases`, `useUiKitPhrases()` | the catalogue, and the hook that resolves it through the application's `Translate` |
70
+ | `useActionDialog()` | the content's state, for anything inside it that enables the action |
71
+ | `FilterInput` | one input for a list's filter: values of predefined `fields` combined with free text, every term a `Tag`; `value`/`onChange` or `defaultValue`; `disabled`, `readOnly` (the tags alone, no input), `error`; the ref is the text input's |
72
+ | `FilterField`, `FilterValue` | what the dropdown offers: a field with an id, a label, an icon, its values, `multiple` (on unless `false`) and a `notice`; a value with a `count`, a `group` and `disabled` |
73
+ | `FilterQuery`, `FilterTerm` | what the list is narrowed by: `FieldTerm` and `TextTerm`, discriminated by `kind`, ids never labels, in the order they were added; `fieldTerm`, `textTerm`, `termKey` |
74
+ | `textOf(query)`, `valuesOf(query, field)` | the two readings a list takes of a query: the free text as one string, and every value picked for a field — empty narrows nothing |
75
+ | `withTerm`, `withoutTerm`, `toggledTerm` | the pure operations on a query the input is built from, with `sameTerm`, `isTextTerm`, `isFieldTerm` and `EMPTY_FILTER` |
76
+ | `filterInputPhrases` | the input's English, part of `uiKitPhrases` |
77
+ | `ThemeSwitcher` | the button that cycles the shared theme — light, dark, system — reading and writing it itself; `className` |
78
+ | `useResolvedTheme()` | the theme to paint, `light` or `dark`, re-rendering when the user switches here or in another tab, or the OS does on `system` |
79
+ | `themeSwitcherPhrases` | the switcher's English, part of `uiKitPhrases` |
80
+ | `NotificationList` | the toast stack over a store from `createNotifications`: one `Toast` per entry with its actions and a close, an action taken dismissing it, two `aria-live` regions announcing them, hover and focus holding a lifetime; `className` places it |
81
+ | `notificationListPhrases` | the stack's English, part of `uiKitPhrases` |
80
82
 
81
83
  ## The filter
82
84
 
@@ -128,4 +130,26 @@ useEffect(() => {
128
130
  Outside a render — a host's `theme`, a script with no framework — `readResolvedTheme` and
129
131
  `subscribeResolvedTheme` from `@enonic/ui-utils` answer the same.
130
132
 
133
+ ## The notifications
134
+
135
+ `NotificationList` renders a store the application created with `createNotifications` from
136
+ `@enonic/ui-utils` — the same split as the theme, store below and component above — and nothing
137
+ else: the kit holds no instance. The application exports its one store, renders the list once
138
+ where its shell puts the corner, and hands the same store to every `Host` it creates for a mount.
139
+ An action taken dismisses the toast, and so does the close; a toast raised with `autoHide: false`
140
+ stays until one or the other; a hovered or focused toast holds its lifetime; a toast closed from
141
+ the keyboard hands its focus to the toast next to it, or back to where the stack was entered from;
142
+ the stack announces an info or a success politely and a warning or an error assertively.
143
+
144
+ ```tsx
145
+ export const notifications = createNotifications();
146
+
147
+ <NotificationList
148
+ notifications={notifications}
149
+ className="fixed right-6 bottom-6 z-60 max-w-115"
150
+ />;
151
+
152
+ notifications.notify('success', 'Deleted', { actions: [{ label: 'Undo', onClick: undo }] });
153
+ ```
154
+
131
155
  Part of the [Enonic UI Toolkit](https://github.com/enonic/npm-enonic-ui-toolkit).
@@ -91,6 +91,9 @@ declare const ActionDialog: {
91
91
  Close: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<import("@enonic/ui").DialogCloseProps> & {
92
92
  ref?: import('preact').Ref<HTMLButtonElement> | undefined;
93
93
  }>;
94
+ Shortcut: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<import("@enonic/ui").DialogShortcutProps> & {
95
+ ref?: import('preact').Ref<HTMLButtonElement> | undefined;
96
+ }>;
94
97
  Title: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<import("@enonic/ui").DialogTitleProps> & {
95
98
  ref?: import('preact').Ref<HTMLHeadingElement> | undefined;
96
99
  }>;
@@ -113,7 +116,7 @@ declare const ActionDialog: {
113
116
  displayName: string;
114
117
  };
115
118
  StepIndicator: {
116
- ({ previousLabel, nextLabel, lastStepLabel, onLastStep, dots, disabled, pending, renderDot }: import("@enonic/ui").DialogStepIndicatorProps): ReactElement;
119
+ ({ previousLabel, nextLabel, lastStepLabel, onLastStep, dots, disabled, pending, keyboardShortcuts, renderDot }: import("@enonic/ui").DialogStepIndicatorProps): ReactElement;
117
120
  displayName: string;
118
121
  };
119
122
  Root: typeof ActionDialogRoot;
@@ -209,25 +209,27 @@ const ActionDialogFooter = forwardRef(({ onConfirm, onCancel, confirmLabel, canc
209
209
  })]
210
210
  }), /* @__PURE__ */ jsxs("div", {
211
211
  className: cn("flex flex-1 items-center justify-end gap-2.5", asking && "hidden"),
212
- children: [
213
- error !== void 0 && /* @__PURE__ */ jsx("p", {
214
- className: "text-error mr-auto text-sm",
215
- role: "alert",
216
- children: error
217
- }),
218
- children,
219
- onConfirm !== void 0 && (closeOnCancel ? /* @__PURE__ */ jsx(Dialog.Close, {
220
- asChild: true,
221
- children: cancel
222
- }) : cancel),
223
- onConfirm !== void 0 && /* @__PURE__ */ jsx(ActionDialogAction, {
224
- ref: confirmRef,
225
- label: confirmLabel ?? t("enonic.uiKit.dialog.confirm"),
226
- intent,
227
- closeOnClick: closeOnConfirm,
228
- onClick: onConfirm
229
- })
230
- ]
212
+ children: [error !== void 0 && /* @__PURE__ */ jsx("p", {
213
+ className: "text-error min-w-0 flex-1 text-sm",
214
+ role: "alert",
215
+ children: error
216
+ }), /* @__PURE__ */ jsxs("div", {
217
+ className: "flex shrink-0 items-center justify-end gap-2.5 only:flex-1",
218
+ children: [
219
+ children,
220
+ onConfirm !== void 0 && (closeOnCancel ? /* @__PURE__ */ jsx(Dialog.Close, {
221
+ asChild: true,
222
+ children: cancel
223
+ }) : cancel),
224
+ onConfirm !== void 0 && /* @__PURE__ */ jsx(ActionDialogAction, {
225
+ ref: confirmRef,
226
+ label: confirmLabel ?? t("enonic.uiKit.dialog.confirm"),
227
+ intent,
228
+ closeOnClick: closeOnConfirm,
229
+ onClick: onConfirm
230
+ })
231
+ ]
232
+ })]
231
233
  })]
232
234
  });
233
235
  });
@@ -1 +1 @@
1
- {"version":3,"file":"action-dialog.js","names":[],"sources":["../../src/dialog/action-dialog.tsx"],"sourcesContent":["import {\n Button,\n type ButtonVariant,\n cn,\n Dialog,\n type DialogRootProps,\n usePrefixedId,\n} from '@enonic/ui';\nimport { TriangleAlert } from 'lucide-react';\nimport {\n type ComponentPropsWithoutRef,\n forwardRef,\n type MouseEvent,\n type ReactElement,\n type ReactNode,\n type Ref,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\n\nimport { useUiKitPhrases } from '../i18n/use-phrases';\nimport {\n type ActionDialogCloseReason,\n type ActionDialogContextValue,\n type ActionDialogOpenChangeDetails,\n ActionDialogProvider,\n ActionDialogRootProvider,\n useActionDialog,\n useActionDialogRoot,\n} from './action-dialog-context';\n\nexport type DialogIntent = 'default' | 'danger';\n\n/** A question needs no more room than its own text; `medium` carries a control, `wide` a form. */\nexport type DialogSize = 'default' | 'medium' | 'wide';\n\nconst SIZES: Record<DialogSize, string> = {\n default: 'max-w-lg',\n medium: 'max-w-180 sm:min-w-152',\n wide: 'max-w-4xl',\n};\n\nconst DANGER_BUTTON_CLASS_NAME =\n 'bg-btn-error text-alt hover:bg-btn-error-hover active:bg-btn-error-active focus-visible:ring-error/50';\n\n//\n// * Root\n//\n\nexport type ActionDialogRootProps = Omit<DialogRootProps, 'onOpenChange'> & {\n /** `details` tells a close an action took from one that would leave without it. */\n onOpenChange?: (open: boolean, details?: ActionDialogOpenChangeDetails) => void;\n};\n\nconst ActionDialogRoot = ({ onOpenChange, ...props }: ActionDialogRootProps): ReactElement => {\n const closeReasonRef = useRef<ActionDialogCloseReason | undefined>(undefined);\n const value = useMemo(() => ({ closeReasonRef }), []);\n\n return (\n <ActionDialogRootProvider value={value}>\n <Dialog.Root\n {...props}\n onOpenChange={(open) => {\n const reason = closeReasonRef.current;\n closeReasonRef.current = undefined;\n onOpenChange?.(open, !open && reason !== undefined ? { reason } : undefined);\n }}\n />\n </ActionDialogRootProvider>\n );\n};\nActionDialogRoot.displayName = 'ActionDialog.Root';\n\n//\n// * Content\n//\n\nexport type ActionDialogContentProps = {\n size?: DialogSize;\n /** Whether the action starts enabled; a content with a gate starts it disabled. */\n defaultConfirmEnabled?: boolean;\n} & ComponentPropsWithoutRef<typeof Dialog.Content>;\n\nconst CONTENT_NAME = 'ActionDialog.Content';\nconst QUESTION_NAME = 'ActionDialog.Question';\nconst CLOSE_SELECTOR = '[data-component=\"Dialog.DefaultClose\"]';\n/** The chrome's controls from `@enonic/ui`; the kit's own carry `keepFocus` themselves. */\nconst CHROME_CONTROL_SELECTOR = `${CLOSE_SELECTOR}, [data-component=\"Dialog.StepIndicator\"]`;\n\n// ! A press that blurs a field can show its messages, grow the centered dialog and move the control\n// ! out from under the pointer before the click lands.\nconst keepFocus = (event: MouseEvent<HTMLElement>): void => event.preventDefault();\n\n/**\n * The dialog's own controls take no focus from a field on a press, so Save runs with the field\n * still focused: a form validated on blur must also validate on confirm.\n */\nconst ActionDialogContent = forwardRef<HTMLDivElement, ActionDialogContentProps>(\n (\n {\n size = 'default',\n defaultConfirmEnabled = true,\n className,\n children,\n onEscapeKeyDown,\n onFocusCapture,\n onPointerDownCapture,\n onClickCapture,\n onClick,\n onMouseDown,\n ...props\n },\n ref,\n ): ReactElement => {\n const [confirmEnabled, setConfirmEnabled] = useState(defaultConfirmEnabled);\n const [asking, setAsking] = useState(false);\n const keepRef = useRef<(() => void) | undefined>(undefined);\n const lastFocusRef = useRef<HTMLElement | null>(null);\n const wasAskingRef = useRef(false);\n const root = useActionDialogRoot();\n\n const context = useMemo<ActionDialogContextValue>(\n () => ({ confirmEnabled, setConfirmEnabled, asking, setAsking, keepRef, lastFocusRef }),\n [confirmEnabled, asking],\n );\n\n // Answered, the focus goes back to the control the question left. The last focus, not the active\n // element: a mask click has already blurred the field to the body.\n useLayoutEffect(() => {\n const target = lastFocusRef.current;\n const answered = wasAskingRef.current && !asking;\n wasAskingRef.current = asking;\n if (!answered || target === null) {\n return;\n }\n // ! A frame later: under Preact the body reads `asking` through context and lifts its `inert`\n // ! in a render after this one, and an inert field takes no focus.\n const frame = requestAnimationFrame(() => target.focus());\n return () => cancelAnimationFrame(frame);\n }, [asking]);\n\n // While the footer asks, a click anywhere but the X and the question is the way back: the control\n // it lands on neither takes the focus nor reacts.\n const keepsEditing = (target: EventTarget | null): boolean =>\n asking &&\n target instanceof Element &&\n target.closest(`${CLOSE_SELECTOR}, [data-component=\"${QUESTION_NAME}\"]`) === null;\n\n return (\n <Dialog.Content\n ref={ref}\n data-component={CONTENT_NAME}\n className={cn('gap-5 p-5 md:p-7.5', SIZES[size], className)}\n onFocusCapture={(event) => {\n onFocusCapture?.(event);\n if (\n event.target instanceof HTMLElement &&\n event.target.closest(`[data-component=\"${QUESTION_NAME}\"]`) === null\n ) {\n lastFocusRef.current = event.target;\n }\n }}\n onPointerDownCapture={(event) => {\n onPointerDownCapture?.(event);\n if (keepsEditing(event.target)) {\n event.preventDefault();\n event.stopPropagation();\n }\n }}\n onClickCapture={(event) => {\n onClickCapture?.(event);\n if (keepsEditing(event.target)) {\n event.preventDefault();\n event.stopPropagation();\n keepRef.current?.();\n } else if (\n root !== undefined &&\n event.target instanceof Element &&\n event.target.closest(CLOSE_SELECTOR) !== null\n ) {\n // ! Runs before the X's own click closes: the close guard reads this to tell the X from the mask.\n root.closeReasonRef.current = 'close';\n }\n }}\n onClick={(event) => {\n onClick?.(event);\n // ! A close took the reason already; one the X's own handler prevented must not leave it behind.\n if (root !== undefined) {\n root.closeReasonRef.current = undefined;\n }\n }}\n onEscapeKeyDown={(event) => {\n onEscapeKeyDown?.(event);\n if (asking && !event.defaultPrevented) {\n event.preventDefault();\n keepRef.current?.();\n }\n }}\n onMouseDown={(event) => {\n onMouseDown?.(event);\n if (\n event.target instanceof Element &&\n event.target.closest(CHROME_CONTROL_SELECTOR) !== null\n ) {\n keepFocus(event);\n }\n }}\n {...props}\n >\n <ActionDialogProvider value={context}>{children}</ActionDialogProvider>\n </Dialog.Content>\n );\n },\n);\nActionDialogContent.displayName = CONTENT_NAME;\n\n//\n// * Body\n//\n\nexport type ActionDialogBodyProps = ComponentPropsWithoutRef<typeof Dialog.Body>;\n\nconst BODY_NAME = 'ActionDialog.Body';\n\nconst ActionDialogBody = forwardRef<HTMLDivElement, ActionDialogBodyProps>(\n ({ className, ...props }, ref): ReactElement => {\n const { asking } = useActionDialog();\n\n return (\n <Dialog.Body\n ref={ref}\n data-component={BODY_NAME}\n className={cn(\n '-mx-2 flex flex-col gap-7 px-2 py-1 transition-opacity',\n asking && 'opacity-50',\n className,\n )}\n {...props}\n // ! `inert`, not a disabled overlay: the form stays in sight but takes no click, no focus.\n inert={asking || props.inert}\n />\n );\n },\n);\nActionDialogBody.displayName = BODY_NAME;\n\n//\n// * Action — a button that takes the dialog's outcome\n//\n\nexport type ActionDialogActionProps = {\n label: string;\n /** `danger` paints the button red, for an action there is no undoing. */\n intent?: DialogIntent;\n /** Enabled while the content allows the action — a gate matched, a form valid — unless disabled outright. */\n disabled?: boolean;\n /** Close the dialog on click; off for a caller that moves on to a progress view instead. */\n closeOnClick?: boolean;\n variant?: ButtonVariant;\n} & Omit<ComponentPropsWithoutRef<typeof Button>, 'label' | 'variant' | 'disabled'>;\n\nconst ACTION_NAME = 'ActionDialog.Action';\n\n/** An action of the footer: enabled by the content, red for a destructive one. */\nconst ActionDialogAction = forwardRef<HTMLButtonElement, ActionDialogActionProps>(\n (\n {\n label,\n intent = 'default',\n disabled = false,\n closeOnClick = true,\n variant = 'solid',\n className,\n onClick,\n onMouseDown,\n ...props\n },\n ref,\n ): ReactElement => {\n const { confirmEnabled } = useActionDialog();\n const root = useActionDialogRoot();\n const button = (\n <Button\n ref={ref}\n data-component={ACTION_NAME}\n variant={variant}\n size=\"lg\"\n label={label}\n disabled={disabled || !confirmEnabled}\n className={cn(intent === 'danger' && DANGER_BUTTON_CLASS_NAME, className)}\n onMouseDown={(event) => {\n onMouseDown?.(event);\n keepFocus(event);\n }}\n onClick={(event) => {\n onClick?.(event);\n // ! Runs before `Dialog.Close` closes: the close guard reads this to let the action through.\n if (closeOnClick && root !== undefined && !event.defaultPrevented) {\n root.closeReasonRef.current = 'action';\n }\n }}\n {...props}\n />\n );\n\n return closeOnClick ? <Dialog.Close asChild>{button}</Dialog.Close> : button;\n },\n);\nActionDialogAction.displayName = ACTION_NAME;\n\n//\n// * Footer\n//\n\n/** A question the footer asks in place of its controls. */\nexport type FooterQuestion = {\n text: ReactNode;\n intent?: DialogIntent;\n /** Defaults to the kit's \"Confirm\". */\n confirmLabel?: string;\n /** The way back; defaults to the kit's \"Keep editing\". */\n keepLabel?: string;\n onConfirm: () => void;\n onKeep: () => void;\n};\n\nexport type ActionDialogFooterProps = {\n /** Given `onConfirm`, the footer renders Cancel and Confirm; without it, its children are the controls. */\n onConfirm?: () => void;\n onCancel?: () => void;\n /** Defaults to the kit's \"Confirm\". */\n confirmLabel?: string;\n /** Defaults to the kit's \"Cancel\". */\n cancelLabel?: string;\n /** `danger` paints the confirm button red, for an action there is no undoing. */\n intent?: DialogIntent;\n /** Why the dialog is still open, shown beside its controls — a rejected save is the case. */\n error?: string;\n /** For a caller that moves the focus onto the confirm button once it means something. */\n confirmRef?: Ref<HTMLButtonElement>;\n /** `outline` gives the two answers of a question equal weight; a form's Cancel stays quiet. */\n cancelVariant?: ButtonVariant;\n /** Close the dialog on Cancel; off for a caller that closes it itself. */\n closeOnCancel?: boolean;\n /** Close the dialog on Confirm; off for a caller that moves on to a progress view instead. */\n closeOnConfirm?: boolean;\n /**\n * While set, the footer asks this instead of showing its controls: the body goes inert, `Escape`\n * is `onKeep`, and the focus goes to the way back. `useCloseGuard` is the usual source.\n */\n question?: FooterQuestion;\n} & ComponentPropsWithoutRef<typeof Dialog.Footer>;\n\nconst FOOTER_NAME = 'ActionDialog.Footer';\n\n/**\n * The footer: Cancel and Confirm, or the controls it is given. While it asks a question, every\n * control hides — hidden, not unmounted, so the one the focus returns to is still there.\n */\nconst ActionDialogFooter = forwardRef<HTMLElement, ActionDialogFooterProps>(\n (\n {\n onConfirm,\n onCancel,\n confirmLabel,\n cancelLabel,\n intent = 'default',\n error,\n confirmRef,\n cancelVariant = 'text',\n closeOnCancel = true,\n closeOnConfirm = true,\n question,\n className,\n children,\n ...props\n },\n ref,\n ): ReactElement => {\n const t = useUiKitPhrases();\n const { setAsking, keepRef } = useActionDialog();\n const asking = question !== undefined;\n const keepButtonRef = useRef<HTMLButtonElement>(null);\n const questionId = usePrefixedId();\n\n keepRef.current = question?.onKeep;\n\n useLayoutEffect(() => {\n setAsking(asking);\n return () => setAsking(false);\n }, [asking, setAsking]);\n\n useEffect(() => {\n if (asking) {\n keepButtonRef.current?.focus();\n }\n }, [asking]);\n\n const cancel = (\n <Button\n variant={cancelVariant}\n size=\"lg\"\n label={cancelLabel ?? t('enonic.uiKit.dialog.cancel')}\n onMouseDown={keepFocus}\n onClick={onCancel}\n />\n );\n\n return (\n <Dialog.Footer\n ref={ref}\n data-component={FOOTER_NAME}\n className={cn('items-center', className)}\n {...props}\n >\n {question !== undefined && (\n <div\n data-component={QUESTION_NAME}\n className=\"flex flex-1 flex-wrap items-center justify-end gap-x-5 gap-y-2.5\"\n >\n {/* Announced as it appears, and read with either answer the focus lands on. */}\n <div id={questionId} role=\"alert\" className=\"mr-auto flex items-center gap-2 text-sm\">\n <TriangleAlert\n className={cn(\n 'size-4 shrink-0',\n question.intent === 'danger' ? 'text-error' : 'text-subtle',\n )}\n />\n {question.text}\n </div>\n <div className=\"flex shrink-0 gap-2.5\">\n <Button\n ref={keepButtonRef}\n aria-describedby={questionId}\n variant=\"outline\"\n size=\"lg\"\n label={question.keepLabel ?? t('enonic.uiKit.dialog.keep')}\n onMouseDown={keepFocus}\n onClick={question.onKeep}\n />\n <Button\n aria-describedby={questionId}\n variant=\"solid\"\n size=\"lg\"\n label={question.confirmLabel ?? t('enonic.uiKit.dialog.confirm')}\n className={cn(question.intent === 'danger' && DANGER_BUTTON_CLASS_NAME)}\n onMouseDown={keepFocus}\n onClick={question.onConfirm}\n />\n </div>\n </div>\n )}\n <div className={cn('flex flex-1 items-center justify-end gap-2.5', asking && 'hidden')}>\n {error !== undefined && (\n <p className=\"text-error mr-auto text-sm\" role=\"alert\">\n {error}\n </p>\n )}\n {children}\n {onConfirm !== undefined &&\n (closeOnCancel ? <Dialog.Close asChild>{cancel}</Dialog.Close> : cancel)}\n {onConfirm !== undefined && (\n <ActionDialogAction\n ref={confirmRef}\n label={confirmLabel ?? t('enonic.uiKit.dialog.confirm')}\n intent={intent}\n closeOnClick={closeOnConfirm}\n onClick={onConfirm}\n />\n )}\n </div>\n </Dialog.Footer>\n );\n },\n);\nActionDialogFooter.displayName = FOOTER_NAME;\n\n//\n// * Compound\n//\n\n/**\n * `Dialog` from `@enonic/ui` plus an outcome: a `Content` that holds whether the action may be\n * taken, a `Body` that goes inert while the footer asks a question, a `Footer` that takes the\n * action or asks the question, and `Action` for a further button the content enables. Stacks\n * nothing: a question goes to the footer, content with room of its own is another `Content` under\n * the same `Root`.\n */\nexport const ActionDialog = {\n ...Dialog,\n Root: ActionDialogRoot,\n Content: ActionDialogContent,\n Body: ActionDialogBody,\n Footer: ActionDialogFooter,\n Action: ActionDialogAction,\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"action-dialog.js","names":[],"sources":["../../src/dialog/action-dialog.tsx"],"sourcesContent":["import {\n Button,\n type ButtonVariant,\n cn,\n Dialog,\n type DialogRootProps,\n usePrefixedId,\n} from '@enonic/ui';\nimport { TriangleAlert } from 'lucide-react';\nimport {\n type ComponentPropsWithoutRef,\n forwardRef,\n type MouseEvent,\n type ReactElement,\n type ReactNode,\n type Ref,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\n\nimport { useUiKitPhrases } from '../i18n/use-phrases';\nimport {\n type ActionDialogCloseReason,\n type ActionDialogContextValue,\n type ActionDialogOpenChangeDetails,\n ActionDialogProvider,\n ActionDialogRootProvider,\n useActionDialog,\n useActionDialogRoot,\n} from './action-dialog-context';\n\nexport type DialogIntent = 'default' | 'danger';\n\n/** A question needs no more room than its own text; `medium` carries a control, `wide` a form. */\nexport type DialogSize = 'default' | 'medium' | 'wide';\n\nconst SIZES: Record<DialogSize, string> = {\n default: 'max-w-lg',\n medium: 'max-w-180 sm:min-w-152',\n wide: 'max-w-4xl',\n};\n\nconst DANGER_BUTTON_CLASS_NAME =\n 'bg-btn-error text-alt hover:bg-btn-error-hover active:bg-btn-error-active focus-visible:ring-error/50';\n\n//\n// * Root\n//\n\nexport type ActionDialogRootProps = Omit<DialogRootProps, 'onOpenChange'> & {\n /** `details` tells a close an action took from one that would leave without it. */\n onOpenChange?: (open: boolean, details?: ActionDialogOpenChangeDetails) => void;\n};\n\nconst ActionDialogRoot = ({ onOpenChange, ...props }: ActionDialogRootProps): ReactElement => {\n const closeReasonRef = useRef<ActionDialogCloseReason | undefined>(undefined);\n const value = useMemo(() => ({ closeReasonRef }), []);\n\n return (\n <ActionDialogRootProvider value={value}>\n <Dialog.Root\n {...props}\n onOpenChange={(open) => {\n const reason = closeReasonRef.current;\n closeReasonRef.current = undefined;\n onOpenChange?.(open, !open && reason !== undefined ? { reason } : undefined);\n }}\n />\n </ActionDialogRootProvider>\n );\n};\nActionDialogRoot.displayName = 'ActionDialog.Root';\n\n//\n// * Content\n//\n\nexport type ActionDialogContentProps = {\n size?: DialogSize;\n /** Whether the action starts enabled; a content with a gate starts it disabled. */\n defaultConfirmEnabled?: boolean;\n} & ComponentPropsWithoutRef<typeof Dialog.Content>;\n\nconst CONTENT_NAME = 'ActionDialog.Content';\nconst QUESTION_NAME = 'ActionDialog.Question';\nconst CLOSE_SELECTOR = '[data-component=\"Dialog.DefaultClose\"]';\n/** The chrome's controls from `@enonic/ui`; the kit's own carry `keepFocus` themselves. */\nconst CHROME_CONTROL_SELECTOR = `${CLOSE_SELECTOR}, [data-component=\"Dialog.StepIndicator\"]`;\n\n// ! A press that blurs a field can show its messages, grow the centered dialog and move the control\n// ! out from under the pointer before the click lands.\nconst keepFocus = (event: MouseEvent<HTMLElement>): void => event.preventDefault();\n\n/**\n * The dialog's own controls take no focus from a field on a press, so Save runs with the field\n * still focused: a form validated on blur must also validate on confirm.\n */\nconst ActionDialogContent = forwardRef<HTMLDivElement, ActionDialogContentProps>(\n (\n {\n size = 'default',\n defaultConfirmEnabled = true,\n className,\n children,\n onEscapeKeyDown,\n onFocusCapture,\n onPointerDownCapture,\n onClickCapture,\n onClick,\n onMouseDown,\n ...props\n },\n ref,\n ): ReactElement => {\n const [confirmEnabled, setConfirmEnabled] = useState(defaultConfirmEnabled);\n const [asking, setAsking] = useState(false);\n const keepRef = useRef<(() => void) | undefined>(undefined);\n const lastFocusRef = useRef<HTMLElement | null>(null);\n const wasAskingRef = useRef(false);\n const root = useActionDialogRoot();\n\n const context = useMemo<ActionDialogContextValue>(\n () => ({ confirmEnabled, setConfirmEnabled, asking, setAsking, keepRef, lastFocusRef }),\n [confirmEnabled, asking],\n );\n\n // Answered, the focus goes back to the control the question left. The last focus, not the active\n // element: a mask click has already blurred the field to the body.\n useLayoutEffect(() => {\n const target = lastFocusRef.current;\n const answered = wasAskingRef.current && !asking;\n wasAskingRef.current = asking;\n if (!answered || target === null) {\n return;\n }\n // ! A frame later: under Preact the body reads `asking` through context and lifts its `inert`\n // ! in a render after this one, and an inert field takes no focus.\n const frame = requestAnimationFrame(() => target.focus());\n return () => cancelAnimationFrame(frame);\n }, [asking]);\n\n // While the footer asks, a click anywhere but the X and the question is the way back: the control\n // it lands on neither takes the focus nor reacts.\n const keepsEditing = (target: EventTarget | null): boolean =>\n asking &&\n target instanceof Element &&\n target.closest(`${CLOSE_SELECTOR}, [data-component=\"${QUESTION_NAME}\"]`) === null;\n\n return (\n <Dialog.Content\n ref={ref}\n data-component={CONTENT_NAME}\n className={cn('gap-5 p-5 md:p-7.5', SIZES[size], className)}\n onFocusCapture={(event) => {\n onFocusCapture?.(event);\n if (\n event.target instanceof HTMLElement &&\n event.target.closest(`[data-component=\"${QUESTION_NAME}\"]`) === null\n ) {\n lastFocusRef.current = event.target;\n }\n }}\n onPointerDownCapture={(event) => {\n onPointerDownCapture?.(event);\n if (keepsEditing(event.target)) {\n event.preventDefault();\n event.stopPropagation();\n }\n }}\n onClickCapture={(event) => {\n onClickCapture?.(event);\n if (keepsEditing(event.target)) {\n event.preventDefault();\n event.stopPropagation();\n keepRef.current?.();\n } else if (\n root !== undefined &&\n event.target instanceof Element &&\n event.target.closest(CLOSE_SELECTOR) !== null\n ) {\n // ! Runs before the X's own click closes: the close guard reads this to tell the X from the mask.\n root.closeReasonRef.current = 'close';\n }\n }}\n onClick={(event) => {\n onClick?.(event);\n // ! A close took the reason already; one the X's own handler prevented must not leave it behind.\n if (root !== undefined) {\n root.closeReasonRef.current = undefined;\n }\n }}\n onEscapeKeyDown={(event) => {\n onEscapeKeyDown?.(event);\n if (asking && !event.defaultPrevented) {\n event.preventDefault();\n keepRef.current?.();\n }\n }}\n onMouseDown={(event) => {\n onMouseDown?.(event);\n if (\n event.target instanceof Element &&\n event.target.closest(CHROME_CONTROL_SELECTOR) !== null\n ) {\n keepFocus(event);\n }\n }}\n {...props}\n >\n <ActionDialogProvider value={context}>{children}</ActionDialogProvider>\n </Dialog.Content>\n );\n },\n);\nActionDialogContent.displayName = CONTENT_NAME;\n\n//\n// * Body\n//\n\nexport type ActionDialogBodyProps = ComponentPropsWithoutRef<typeof Dialog.Body>;\n\nconst BODY_NAME = 'ActionDialog.Body';\n\nconst ActionDialogBody = forwardRef<HTMLDivElement, ActionDialogBodyProps>(\n ({ className, ...props }, ref): ReactElement => {\n const { asking } = useActionDialog();\n\n return (\n <Dialog.Body\n ref={ref}\n data-component={BODY_NAME}\n className={cn(\n '-mx-2 flex flex-col gap-7 px-2 py-1 transition-opacity',\n asking && 'opacity-50',\n className,\n )}\n {...props}\n // ! `inert`, not a disabled overlay: the form stays in sight but takes no click, no focus.\n inert={asking || props.inert}\n />\n );\n },\n);\nActionDialogBody.displayName = BODY_NAME;\n\n//\n// * Action — a button that takes the dialog's outcome\n//\n\nexport type ActionDialogActionProps = {\n label: string;\n /** `danger` paints the button red, for an action there is no undoing. */\n intent?: DialogIntent;\n /** Enabled while the content allows the action — a gate matched, a form valid — unless disabled outright. */\n disabled?: boolean;\n /** Close the dialog on click; off for a caller that moves on to a progress view instead. */\n closeOnClick?: boolean;\n variant?: ButtonVariant;\n} & Omit<ComponentPropsWithoutRef<typeof Button>, 'label' | 'variant' | 'disabled'>;\n\nconst ACTION_NAME = 'ActionDialog.Action';\n\n/** An action of the footer: enabled by the content, red for a destructive one. */\nconst ActionDialogAction = forwardRef<HTMLButtonElement, ActionDialogActionProps>(\n (\n {\n label,\n intent = 'default',\n disabled = false,\n closeOnClick = true,\n variant = 'solid',\n className,\n onClick,\n onMouseDown,\n ...props\n },\n ref,\n ): ReactElement => {\n const { confirmEnabled } = useActionDialog();\n const root = useActionDialogRoot();\n const button = (\n <Button\n ref={ref}\n data-component={ACTION_NAME}\n variant={variant}\n size=\"lg\"\n label={label}\n disabled={disabled || !confirmEnabled}\n className={cn(intent === 'danger' && DANGER_BUTTON_CLASS_NAME, className)}\n onMouseDown={(event) => {\n onMouseDown?.(event);\n keepFocus(event);\n }}\n onClick={(event) => {\n onClick?.(event);\n // ! Runs before `Dialog.Close` closes: the close guard reads this to let the action through.\n if (closeOnClick && root !== undefined && !event.defaultPrevented) {\n root.closeReasonRef.current = 'action';\n }\n }}\n {...props}\n />\n );\n\n return closeOnClick ? <Dialog.Close asChild>{button}</Dialog.Close> : button;\n },\n);\nActionDialogAction.displayName = ACTION_NAME;\n\n//\n// * Footer\n//\n\n/** A question the footer asks in place of its controls. */\nexport type FooterQuestion = {\n text: ReactNode;\n intent?: DialogIntent;\n /** Defaults to the kit's \"Confirm\". */\n confirmLabel?: string;\n /** The way back; defaults to the kit's \"Keep editing\". */\n keepLabel?: string;\n onConfirm: () => void;\n onKeep: () => void;\n};\n\nexport type ActionDialogFooterProps = {\n /** Given `onConfirm`, the footer renders Cancel and Confirm; without it, its children are the controls. */\n onConfirm?: () => void;\n onCancel?: () => void;\n /** Defaults to the kit's \"Confirm\". */\n confirmLabel?: string;\n /** Defaults to the kit's \"Cancel\". */\n cancelLabel?: string;\n /** `danger` paints the confirm button red, for an action there is no undoing. */\n intent?: DialogIntent;\n /** Why the dialog is still open, shown beside its controls — a rejected save is the case. */\n error?: string;\n /** For a caller that moves the focus onto the confirm button once it means something. */\n confirmRef?: Ref<HTMLButtonElement>;\n /** `outline` gives the two answers of a question equal weight; a form's Cancel stays quiet. */\n cancelVariant?: ButtonVariant;\n /** Close the dialog on Cancel; off for a caller that closes it itself. */\n closeOnCancel?: boolean;\n /** Close the dialog on Confirm; off for a caller that moves on to a progress view instead. */\n closeOnConfirm?: boolean;\n /**\n * While set, the footer asks this instead of showing its controls: the body goes inert, `Escape`\n * is `onKeep`, and the focus goes to the way back. `useCloseGuard` is the usual source.\n */\n question?: FooterQuestion;\n} & ComponentPropsWithoutRef<typeof Dialog.Footer>;\n\nconst FOOTER_NAME = 'ActionDialog.Footer';\n\n/**\n * The footer: Cancel and Confirm, or the controls it is given. While it asks a question, every\n * control hides — hidden, not unmounted, so the one the focus returns to is still there.\n */\nconst ActionDialogFooter = forwardRef<HTMLElement, ActionDialogFooterProps>(\n (\n {\n onConfirm,\n onCancel,\n confirmLabel,\n cancelLabel,\n intent = 'default',\n error,\n confirmRef,\n cancelVariant = 'text',\n closeOnCancel = true,\n closeOnConfirm = true,\n question,\n className,\n children,\n ...props\n },\n ref,\n ): ReactElement => {\n const t = useUiKitPhrases();\n const { setAsking, keepRef } = useActionDialog();\n const asking = question !== undefined;\n const keepButtonRef = useRef<HTMLButtonElement>(null);\n const questionId = usePrefixedId();\n\n keepRef.current = question?.onKeep;\n\n useLayoutEffect(() => {\n setAsking(asking);\n return () => setAsking(false);\n }, [asking, setAsking]);\n\n useEffect(() => {\n if (asking) {\n keepButtonRef.current?.focus();\n }\n }, [asking]);\n\n const cancel = (\n <Button\n variant={cancelVariant}\n size=\"lg\"\n label={cancelLabel ?? t('enonic.uiKit.dialog.cancel')}\n onMouseDown={keepFocus}\n onClick={onCancel}\n />\n );\n\n return (\n <Dialog.Footer\n ref={ref}\n data-component={FOOTER_NAME}\n className={cn('items-center', className)}\n {...props}\n >\n {question !== undefined && (\n <div\n data-component={QUESTION_NAME}\n className=\"flex flex-1 flex-wrap items-center justify-end gap-x-5 gap-y-2.5\"\n >\n {/* Announced as it appears, and read with either answer the focus lands on. */}\n <div id={questionId} role=\"alert\" className=\"mr-auto flex items-center gap-2 text-sm\">\n <TriangleAlert\n className={cn(\n 'size-4 shrink-0',\n question.intent === 'danger' ? 'text-error' : 'text-subtle',\n )}\n />\n {question.text}\n </div>\n <div className=\"flex shrink-0 gap-2.5\">\n <Button\n ref={keepButtonRef}\n aria-describedby={questionId}\n variant=\"outline\"\n size=\"lg\"\n label={question.keepLabel ?? t('enonic.uiKit.dialog.keep')}\n onMouseDown={keepFocus}\n onClick={question.onKeep}\n />\n <Button\n aria-describedby={questionId}\n variant=\"solid\"\n size=\"lg\"\n label={question.confirmLabel ?? t('enonic.uiKit.dialog.confirm')}\n className={cn(question.intent === 'danger' && DANGER_BUTTON_CLASS_NAME)}\n onMouseDown={keepFocus}\n onClick={question.onConfirm}\n />\n </div>\n </div>\n )}\n <div className={cn('flex flex-1 items-center justify-end gap-2.5', asking && 'hidden')}>\n {error !== undefined && (\n <p className=\"text-error min-w-0 flex-1 text-sm\" role=\"alert\">\n {error}\n </p>\n )}\n <div className=\"flex shrink-0 items-center justify-end gap-2.5 only:flex-1\">\n {children}\n {onConfirm !== undefined &&\n (closeOnCancel ? <Dialog.Close asChild>{cancel}</Dialog.Close> : cancel)}\n {onConfirm !== undefined && (\n <ActionDialogAction\n ref={confirmRef}\n label={confirmLabel ?? t('enonic.uiKit.dialog.confirm')}\n intent={intent}\n closeOnClick={closeOnConfirm}\n onClick={onConfirm}\n />\n )}\n </div>\n </div>\n </Dialog.Footer>\n );\n },\n);\nActionDialogFooter.displayName = FOOTER_NAME;\n\n//\n// * Compound\n//\n\n/**\n * `Dialog` from `@enonic/ui` plus an outcome: a `Content` that holds whether the action may be\n * taken, a `Body` that goes inert while the footer asks a question, a `Footer` that takes the\n * action or asks the question, and `Action` for a further button the content enables. Stacks\n * nothing: a question goes to the footer, content with room of its own is another `Content` under\n * the same `Root`.\n */\nexport const ActionDialog = {\n ...Dialog,\n Root: ActionDialogRoot,\n Content: ActionDialogContent,\n Body: ActionDialogBody,\n Footer: ActionDialogFooter,\n Action: ActionDialogAction,\n};\n"],"mappings":"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"}
@@ -19,6 +19,8 @@ declare const uiKitPhrases: Readonly<{
19
19
  readonly 'enonic.uiKit.filterInput.added': 'Added {0}';
20
20
  readonly 'enonic.uiKit.filterInput.removed': 'Removed {0}';
21
21
  readonly 'enonic.uiKit.filterInput.replaced': 'Replaced {0} with {1}';
22
+ } & {
23
+ readonly 'enonic.uiKit.notifications.label': 'Notifications';
22
24
  } & {
23
25
  readonly 'enonic.uiKit.themeSwitcher.light': 'Light theme';
24
26
  readonly 'enonic.uiKit.themeSwitcher.dark': 'Dark theme';
@@ -1,4 +1,5 @@
1
1
  import { filterInputPhrases } from "../filter/filter-input.phrases.js";
2
+ import { notificationListPhrases } from "../notifications/notification-list.phrases.js";
2
3
  import { themeSwitcherPhrases } from "../theme/theme-switcher.phrases.js";
3
4
  import { mergePhrases } from "@enonic/ui-utils";
4
5
  //#region src/i18n/phrases.ts
@@ -22,7 +23,8 @@ const uiKitPhrases = mergePhrases([
22
23
  dialogPhrases,
23
24
  gatePhrases,
24
25
  filterInputPhrases,
25
- themeSwitcherPhrases
26
+ themeSwitcherPhrases,
27
+ notificationListPhrases
26
28
  ]);
27
29
  //#endregion
28
30
  export { dialogPhrases, gatePhrases, uiKitPhrases };
@@ -1 +1 @@
1
- {"version":3,"file":"phrases.js","names":[],"sources":["../../src/i18n/phrases.ts"],"sourcesContent":["import { mergePhrases } from '@enonic/ui-utils';\n\nimport { filterInputPhrases } from '../filter/filter-input.phrases';\nimport { themeSwitcherPhrases } from '../theme/theme-switcher.phrases';\n\nexport const dialogPhrases = {\n 'enonic.uiKit.dialog.cancel': 'Cancel',\n 'enonic.uiKit.dialog.confirm': 'Confirm',\n 'enonic.uiKit.dialog.delete': 'Delete',\n 'enonic.uiKit.dialog.keep': 'Keep editing',\n} as const;\n\nexport const gatePhrases = {\n 'enonic.uiKit.gate.hint': 'Type {0} to confirm',\n 'enonic.uiKit.gate.mismatch': '{1} does not match {0}',\n} as const;\n\n/**\n * Every text the package renders, in English, keyed `enonic.uiKit.<area>.<name>`. An application\n * translates them through the `Translate` it hands `@enonic/ui`'s `I18nProvider`; a key it has no\n * text for renders this English. `comparePhrases` from `@enonic/ui-utils` tells an application\n * which of these its bundle lacks.\n */\nexport const uiKitPhrases = mergePhrases([\n dialogPhrases,\n gatePhrases,\n filterInputPhrases,\n themeSwitcherPhrases,\n]);\n\nexport type UiKitPhraseKey = keyof typeof uiKitPhrases;\n"],"mappings":";;;;AAKA,MAAa,gBAAgB;CAC3B,8BAA8B;CAC9B,+BAA+B;CAC/B,8BAA8B;CAC9B,4BAA4B;AAC9B;AAEA,MAAa,cAAc;CACzB,0BAA0B;CAC1B,8BAA8B;AAChC;;;;;;;AAQA,MAAa,eAAe,aAAa;CACvC;CACA;CACA;CACA;AACF,CAAC"}
1
+ {"version":3,"file":"phrases.js","names":[],"sources":["../../src/i18n/phrases.ts"],"sourcesContent":["import { mergePhrases } from '@enonic/ui-utils';\n\nimport { filterInputPhrases } from '../filter/filter-input.phrases';\nimport { notificationListPhrases } from '../notifications/notification-list.phrases';\nimport { themeSwitcherPhrases } from '../theme/theme-switcher.phrases';\n\nexport const dialogPhrases = {\n 'enonic.uiKit.dialog.cancel': 'Cancel',\n 'enonic.uiKit.dialog.confirm': 'Confirm',\n 'enonic.uiKit.dialog.delete': 'Delete',\n 'enonic.uiKit.dialog.keep': 'Keep editing',\n} as const;\n\nexport const gatePhrases = {\n 'enonic.uiKit.gate.hint': 'Type {0} to confirm',\n 'enonic.uiKit.gate.mismatch': '{1} does not match {0}',\n} as const;\n\n/**\n * Every text the package renders, in English, keyed `enonic.uiKit.<area>.<name>`. An application\n * translates them through the `Translate` it hands `@enonic/ui`'s `I18nProvider`; a key it has no\n * text for renders this English. `comparePhrases` from `@enonic/ui-utils` tells an application\n * which of these its bundle lacks.\n */\nexport const uiKitPhrases = mergePhrases([\n dialogPhrases,\n gatePhrases,\n filterInputPhrases,\n themeSwitcherPhrases,\n notificationListPhrases,\n]);\n\nexport type UiKitPhraseKey = keyof typeof uiKitPhrases;\n"],"mappings":";;;;;AAMA,MAAa,gBAAgB;CAC3B,8BAA8B;CAC9B,+BAA+B;CAC/B,8BAA8B;CAC9B,4BAA4B;AAC9B;AAEA,MAAa,cAAc;CACzB,0BAA0B;CAC1B,8BAA8B;AAChC;;;;;;;AAQA,MAAa,eAAe,aAAa;CACvC;CACA;CACA;CACA;CACA;AACF,CAAC"}
package/dist/index.d.ts CHANGED
@@ -13,7 +13,9 @@ import { FilterInputPhraseKey, filterInputPhrases } from "./filter/filter-input.
13
13
  import { fillPhrase } from "./i18n/fill-phrase.js";
14
14
  import { UiKitPhraseKey, uiKitPhrases } from "./i18n/phrases.js";
15
15
  import { UiKitTranslate, useUiKitPhrases } from "./i18n/use-phrases.js";
16
+ import { NotificationList, NotificationListProps } from "./notifications/notification-list.js";
17
+ import { NotificationListPhraseKey, notificationListPhrases } from "./notifications/notification-list.phrases.js";
16
18
  import { ThemeSwitcher, ThemeSwitcherProps } from "./theme/theme-switcher.js";
17
19
  import { ThemeSwitcherPhraseKey, themeSwitcherPhrases } from "./theme/theme-switcher.phrases.js";
18
20
  import { useResolvedTheme } from "./theme/use-resolved-theme.js";
19
- export { ActionDialog, type ActionDialogActionProps, type ActionDialogBodyProps, type ActionDialogCloseReason, type ActionDialogContentProps, type ActionDialogContextValue, type ActionDialogFooterProps, type ActionDialogOpenChangeDetails, type ActionDialogRootProps, type CloseGuard, type CloseGuardOptions, DeleteConfirm, type DeleteConfirmProps, type DeleteTarget, type DialogIntent, DialogPreset, type DialogPresetConfirmProps, type DialogSize, EMPTY_FILTER, type FieldTerm, type FilterField, FilterInput, type FilterInputPhraseKey, type FilterInputProps, type FilterQuery, type FilterTerm, type FilterValue, type FooterQuestion, Gate, type GateHintProps, type GateInputProps, type TextTerm, ThemeSwitcher, type ThemeSwitcherPhraseKey, type ThemeSwitcherProps, type UiKitPhraseKey, type UiKitTranslate, deleteExpectation, fieldTerm, fillPhrase, filterInputPhrases, isFieldTerm, isTextTerm, matchesExpected, sameTerm, termKey, textOf, textTerm, themeSwitcherPhrases, toggledTerm, uiKitPhrases, useActionDialog, useCloseGuard, useResolvedTheme, useUiKitPhrases, valuesOf, withTerm, withoutTerm };
21
+ export { ActionDialog, type ActionDialogActionProps, type ActionDialogBodyProps, type ActionDialogCloseReason, type ActionDialogContentProps, type ActionDialogContextValue, type ActionDialogFooterProps, type ActionDialogOpenChangeDetails, type ActionDialogRootProps, type CloseGuard, type CloseGuardOptions, DeleteConfirm, type DeleteConfirmProps, type DeleteTarget, type DialogIntent, DialogPreset, type DialogPresetConfirmProps, type DialogSize, EMPTY_FILTER, type FieldTerm, type FilterField, FilterInput, type FilterInputPhraseKey, type FilterInputProps, type FilterQuery, type FilterTerm, type FilterValue, type FooterQuestion, Gate, type GateHintProps, type GateInputProps, NotificationList, type NotificationListPhraseKey, type NotificationListProps, type TextTerm, ThemeSwitcher, type ThemeSwitcherPhraseKey, type ThemeSwitcherProps, type UiKitPhraseKey, type UiKitTranslate, deleteExpectation, fieldTerm, fillPhrase, filterInputPhrases, isFieldTerm, isTextTerm, matchesExpected, notificationListPhrases, sameTerm, termKey, textOf, textTerm, themeSwitcherPhrases, toggledTerm, uiKitPhrases, useActionDialog, useCloseGuard, useResolvedTheme, useUiKitPhrases, valuesOf, withTerm, withoutTerm };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useActionDialog } from "./dialog/action-dialog-context.js";
2
2
  import { filterInputPhrases } from "./filter/filter-input.phrases.js";
3
+ import { notificationListPhrases } from "./notifications/notification-list.phrases.js";
3
4
  import { themeSwitcherPhrases } from "./theme/theme-switcher.phrases.js";
4
5
  import { uiKitPhrases } from "./i18n/phrases.js";
5
6
  import { useUiKitPhrases } from "./i18n/use-phrases.js";
@@ -13,6 +14,7 @@ import { DialogPreset } from "./dialog/dialog-preset.js";
13
14
  import { useCloseGuard } from "./dialog/use-close-guard.js";
14
15
  import { EMPTY_FILTER, fieldTerm, isFieldTerm, isTextTerm, sameTerm, termKey, textOf, textTerm, toggledTerm, valuesOf, withTerm, withoutTerm } from "./filter/filter-query.js";
15
16
  import { FilterInput } from "./filter/filter-input.js";
17
+ import { NotificationList } from "./notifications/notification-list.js";
16
18
  import { ThemeSwitcher } from "./theme/theme-switcher.js";
17
19
  import { useResolvedTheme } from "./theme/use-resolved-theme.js";
18
- export { ActionDialog, DeleteConfirm, DialogPreset, EMPTY_FILTER, FilterInput, Gate, ThemeSwitcher, deleteExpectation, fieldTerm, fillPhrase, filterInputPhrases, isFieldTerm, isTextTerm, matchesExpected, sameTerm, termKey, textOf, textTerm, themeSwitcherPhrases, toggledTerm, uiKitPhrases, useActionDialog, useCloseGuard, useResolvedTheme, useUiKitPhrases, valuesOf, withTerm, withoutTerm };
20
+ export { ActionDialog, DeleteConfirm, DialogPreset, EMPTY_FILTER, FilterInput, Gate, NotificationList, ThemeSwitcher, deleteExpectation, fieldTerm, fillPhrase, filterInputPhrases, isFieldTerm, isTextTerm, matchesExpected, notificationListPhrases, sameTerm, termKey, textOf, textTerm, themeSwitcherPhrases, toggledTerm, uiKitPhrases, useActionDialog, useCloseGuard, useResolvedTheme, useUiKitPhrases, valuesOf, withTerm, withoutTerm };
@@ -0,0 +1,18 @@
1
+ import { ReactElement } from "react";
2
+ import { Notifications } from "@enonic/ui-utils";
3
+ //#region src/notifications/notification-list.d.ts
4
+ type NotificationListProps = {
5
+ /** The store the application created with `createNotifications`. */
6
+ notifications: Pick<Notifications, 'notifications' | 'dismiss' | 'pause' | 'resume'>;
7
+ /** Where the stack sits: the application places it, since where the corner is depends on the shell. */
8
+ className?: string;
9
+ 'data-component'?: string;
10
+ };
11
+ declare const NOTIFICATION_LIST_NAME = "NotificationList";
12
+ declare function NotificationList({ notifications: store, className, 'data-component': componentName }: NotificationListProps): ReactElement;
13
+ declare namespace NotificationList {
14
+ export { NOTIFICATION_LIST_NAME as displayName };
15
+ }
16
+ //#endregion
17
+ export { NotificationList, NotificationListProps };
18
+ //# sourceMappingURL=notification-list.d.ts.map
@@ -0,0 +1,92 @@
1
+ import { notificationListPhrases } from "./notification-list.phrases.js";
2
+ import { hasFocus, useFocusHandOff } from "./use-focus-hand-off.js";
3
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { Toast, cn, usePhrases } from "@enonic/ui";
6
+ import { isUrgent } from "@enonic/ui-utils";
7
+ //#region src/notifications/notification-list.tsx
8
+ const NOTIFICATION_LIST_NAME = "NotificationList";
9
+ /**
10
+ * The toasts on screen, one `Toast` each with its actions and a close, and two `aria-live` regions
11
+ * that announce them. Hover and focus pause a toast's lifetime, so an action is never pulled out
12
+ * from under a pointer or a keyboard user halfway to it; an action taken dismisses the toast. A
13
+ * toast closed from the keyboard hands its focus to the toast next to it, or back to where the
14
+ * focus came from when the stack was entered.
15
+ */
16
+ const NotificationList = ({ notifications: store, className, "data-component": componentName = NOTIFICATION_LIST_NAME }) => {
17
+ const readable = store.notifications;
18
+ const subscribe = useCallback((onChange) => readable.listen(onChange), [readable]);
19
+ const read = useCallback(() => readable.get(), [readable]);
20
+ const notifications = useSyncExternalStore(subscribe, read);
21
+ const t = usePhrases(notificationListPhrases);
22
+ const [mounted, setMounted] = useState(false);
23
+ useEffect(() => {
24
+ setMounted(true);
25
+ }, []);
26
+ const announced = mounted ? notifications : [];
27
+ const wrappers = useRef(/* @__PURE__ */ new Map());
28
+ const { stackProps, handOff } = useFocusHandOff();
29
+ const dismissFrom = (id) => {
30
+ handOff(wrappers.current.get(id));
31
+ store.dismiss(id);
32
+ };
33
+ return /* @__PURE__ */ jsxs("div", {
34
+ "data-component": componentName,
35
+ role: "region",
36
+ "aria-label": t("enonic.uiKit.notifications.label"),
37
+ className: cn("pointer-events-none", className),
38
+ children: [
39
+ /* @__PURE__ */ jsx("div", {
40
+ className: "sr-only",
41
+ "aria-live": "polite",
42
+ children: announced.filter(({ tone }) => !isUrgent(tone)).map(({ id, message }) => /* @__PURE__ */ jsx("p", { children: message }, id))
43
+ }),
44
+ /* @__PURE__ */ jsx("div", {
45
+ className: "sr-only",
46
+ "aria-live": "assertive",
47
+ children: announced.filter(({ tone }) => isUrgent(tone)).map(({ id, message }) => /* @__PURE__ */ jsx("p", { children: message }, id))
48
+ }),
49
+ /* @__PURE__ */ jsx("div", {
50
+ ...stackProps,
51
+ className: "flex flex-col items-center gap-2.5",
52
+ children: notifications.map(({ id, tone, message, actions }) => /* @__PURE__ */ jsx("div", {
53
+ ref: (element) => {
54
+ if (element == null) wrappers.current.delete(id);
55
+ else wrappers.current.set(id, element);
56
+ },
57
+ className: "pointer-events-auto w-full",
58
+ onMouseEnter: () => store.pause(id),
59
+ onMouseLeave: ({ currentTarget }) => !hasFocus(currentTarget) && store.resume(id),
60
+ onFocusCapture: () => store.pause(id),
61
+ onBlurCapture: ({ currentTarget }) => !currentTarget.matches(":hover") && store.resume(id),
62
+ children: /* @__PURE__ */ jsxs(Toast, {
63
+ open: true,
64
+ withClose: true,
65
+ role: "status",
66
+ "aria-live": "off",
67
+ onOpenChange: (open) => !open && dismissFrom(id),
68
+ children: [
69
+ /* @__PURE__ */ jsx(Toast.Icon, { tone }),
70
+ /* @__PURE__ */ jsx(Toast.Description, { children: message }),
71
+ actions.map(({ label, onClick }, index) => /* @__PURE__ */ jsx(Toast.Button, {
72
+ onClick: () => {
73
+ try {
74
+ onClick();
75
+ } finally {
76
+ dismissFrom(id);
77
+ }
78
+ },
79
+ children: label
80
+ }, `${index}-${label}`))
81
+ ]
82
+ })
83
+ }, id))
84
+ })
85
+ ]
86
+ });
87
+ };
88
+ NotificationList.displayName = NOTIFICATION_LIST_NAME;
89
+ //#endregion
90
+ export { NotificationList };
91
+
92
+ //# sourceMappingURL=notification-list.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-list.js","names":[],"sources":["../../src/notifications/notification-list.tsx"],"sourcesContent":["import { cn, Toast, usePhrases } from '@enonic/ui';\nimport { isUrgent, type Notifications } from '@enonic/ui-utils';\nimport {\n type ReactElement,\n useCallback,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n} from 'react';\n\nimport { notificationListPhrases } from './notification-list.phrases';\nimport { hasFocus, useFocusHandOff } from './use-focus-hand-off';\n\nexport type NotificationListProps = {\n /** The store the application created with `createNotifications`. */\n notifications: Pick<Notifications, 'notifications' | 'dismiss' | 'pause' | 'resume'>;\n /** Where the stack sits: the application places it, since where the corner is depends on the shell. */\n className?: string;\n 'data-component'?: string;\n};\n\nconst NOTIFICATION_LIST_NAME = 'NotificationList';\n\n/**\n * The toasts on screen, one `Toast` each with its actions and a close, and two `aria-live` regions\n * that announce them. Hover and focus pause a toast's lifetime, so an action is never pulled out\n * from under a pointer or a keyboard user halfway to it; an action taken dismisses the toast. A\n * toast closed from the keyboard hands its focus to the toast next to it, or back to where the\n * focus came from when the stack was entered.\n */\nconst NotificationList = ({\n notifications: store,\n className,\n 'data-component': componentName = NOTIFICATION_LIST_NAME,\n}: NotificationListProps): ReactElement => {\n const readable = store.notifications;\n const subscribe = useCallback((onChange: () => void) => readable.listen(onChange), [readable]);\n const read = useCallback(() => readable.get(), [readable]);\n const notifications = useSyncExternalStore(subscribe, read);\n const t = usePhrases(notificationListPhrases);\n\n // ! Announced reliably only if the region predates its content: the regions stay mounted, the\n // ! toasts themselves are silenced with `aria-live=\"off\"`, and what the store held before the\n // ! list mounted enters the regions one render after they appeared.\n const [mounted, setMounted] = useState(false);\n useEffect(() => {\n setMounted(true);\n }, []);\n const announced = mounted ? notifications : [];\n\n const wrappers = useRef(new Map<number, HTMLDivElement>());\n const { stackProps, handOff } = useFocusHandOff();\n\n const dismissFrom = (id: number): void => {\n handOff(wrappers.current.get(id));\n store.dismiss(id);\n };\n\n return (\n <div\n data-component={componentName}\n role=\"region\"\n aria-label={t('enonic.uiKit.notifications.label')}\n className={cn('pointer-events-none', className)}\n >\n <div className=\"sr-only\" aria-live=\"polite\">\n {announced\n .filter(({ tone }) => !isUrgent(tone))\n .map(({ id, message }) => (\n <p key={id}>{message}</p>\n ))}\n </div>\n <div className=\"sr-only\" aria-live=\"assertive\">\n {announced\n .filter(({ tone }) => isUrgent(tone))\n .map(({ id, message }) => (\n <p key={id}>{message}</p>\n ))}\n </div>\n\n <div {...stackProps} className=\"flex flex-col items-center gap-2.5\">\n {notifications.map(({ id, tone, message, actions }) => (\n <div\n key={id}\n ref={(element) => {\n if (element == null) {\n wrappers.current.delete(id);\n } else {\n wrappers.current.set(id, element);\n }\n }}\n className=\"pointer-events-auto w-full\"\n onMouseEnter={() => store.pause(id)}\n onMouseLeave={({ currentTarget }) => !hasFocus(currentTarget) && store.resume(id)}\n onFocusCapture={() => store.pause(id)}\n onBlurCapture={({ currentTarget }) =>\n !currentTarget.matches(':hover') && store.resume(id)\n }\n >\n <Toast\n open\n withClose\n role=\"status\"\n aria-live=\"off\"\n onOpenChange={(open) => !open && dismissFrom(id)}\n >\n <Toast.Icon tone={tone} />\n <Toast.Description>{message}</Toast.Description>\n {actions.map(({ label, onClick }, index) => (\n <Toast.Button\n key={`${index}-${label}`}\n onClick={() => {\n try {\n onClick();\n } finally {\n dismissFrom(id);\n }\n }}\n >\n {label}\n </Toast.Button>\n ))}\n </Toast>\n </div>\n ))}\n </div>\n </div>\n );\n};\n\nNotificationList.displayName = NOTIFICATION_LIST_NAME;\n\nexport { NotificationList };\n"],"mappings":";;;;;;;AAsBA,MAAM,yBAAyB;;;;;;;;AAS/B,MAAM,oBAAoB,EACxB,eAAe,OACf,WACA,kBAAkB,gBAAgB,6BACO;CACzC,MAAM,WAAW,MAAM;CACvB,MAAM,YAAY,aAAa,aAAyB,SAAS,OAAO,QAAQ,GAAG,CAAC,QAAQ,CAAC;CAC7F,MAAM,OAAO,kBAAkB,SAAS,IAAI,GAAG,CAAC,QAAQ,CAAC;CACzD,MAAM,gBAAgB,qBAAqB,WAAW,IAAI;CAC1D,MAAM,IAAI,WAAW,uBAAuB;CAK5C,MAAM,CAAC,SAAS,cAAc,SAAS,KAAK;CAC5C,gBAAgB;EACd,WAAW,IAAI;CACjB,GAAG,CAAC,CAAC;CACL,MAAM,YAAY,UAAU,gBAAgB,CAAC;CAE7C,MAAM,WAAW,uBAAO,IAAI,IAA4B,CAAC;CACzD,MAAM,EAAE,YAAY,YAAY,gBAAgB;CAEhD,MAAM,eAAe,OAAqB;EACxC,QAAQ,SAAS,QAAQ,IAAI,EAAE,CAAC;EAChC,MAAM,QAAQ,EAAE;CAClB;CAEA,OACE,qBAAC,OAAD;EACE,kBAAgB;EAChB,MAAK;EACL,cAAY,EAAE,kCAAkC;EAChD,WAAW,GAAG,uBAAuB,SAAS;EAJhD,UAAA;GAME,oBAAC,OAAD;IAAK,WAAU;IAAU,aAAU;IAChC,UAAA,UACE,QAAQ,EAAE,WAAW,CAAC,SAAS,IAAI,CAAC,CAAC,CACrC,KAAK,EAAE,IAAI,cACV,oBAAC,KAAD,EAAA,UAAa,QAAW,GAAhB,EAAgB,CACzB;GACA,CAAA;GACL,oBAAC,OAAD;IAAK,WAAU;IAAU,aAAU;IAChC,UAAA,UACE,QAAQ,EAAE,WAAW,SAAS,IAAI,CAAC,CAAC,CACpC,KAAK,EAAE,IAAI,cACV,oBAAC,KAAD,EAAA,UAAa,QAAW,GAAhB,EAAgB,CACzB;GACA,CAAA;GAEL,oBAAC,OAAD;IAAK,GAAI;IAAY,WAAU;IAC5B,UAAA,cAAc,KAAK,EAAE,IAAI,MAAM,SAAS,cACvC,oBAAC,OAAD;KAEE,MAAM,YAAY;MAChB,IAAI,WAAW,MACb,SAAS,QAAQ,OAAO,EAAE;WAE1B,SAAS,QAAQ,IAAI,IAAI,OAAO;KAEpC;KACA,WAAU;KACV,oBAAoB,MAAM,MAAM,EAAE;KAClC,eAAe,EAAE,oBAAoB,CAAC,SAAS,aAAa,KAAK,MAAM,OAAO,EAAE;KAChF,sBAAsB,MAAM,MAAM,EAAE;KACpC,gBAAgB,EAAE,oBAChB,CAAC,cAAc,QAAQ,QAAQ,KAAK,MAAM,OAAO,EAAE;KAGrD,UAAA,qBAAC,OAAD;MACE,MAAA;MACA,WAAA;MACA,MAAK;MACL,aAAU;MACV,eAAe,SAAS,CAAC,QAAQ,YAAY,EAAE;MALjD,UAAA;OAOE,oBAAC,MAAM,MAAP,EAAkB,KAAO,CAAA;OACzB,oBAAC,MAAM,aAAP,EAAA,UAAoB,QAA2B,CAAA;OAC9C,QAAQ,KAAK,EAAE,OAAO,WAAW,UAChC,oBAAC,MAAM,QAAP;QAEE,eAAe;SACb,IAAI;UACF,QAAQ;SACV,UAAU;UACR,YAAY,EAAE;SAChB;QACF;QAEC,UAAA;OACW,GAVP,GAAG,MAAM,GAAG,OAUL,CACf;MACI;;IACJ,GAxCE,EAwCF,CACN;GACE,CAAA;EACF;;AAET;AAEA,iBAAiB,cAAc"}
@@ -0,0 +1,9 @@
1
+ //#region src/notifications/notification-list.phrases.d.ts
2
+ /** The stack's own English, keyed `enonic.uiKit.notifications.<name>`; see `uiKitPhrases`. */
3
+ declare const notificationListPhrases: {
4
+ readonly 'enonic.uiKit.notifications.label': 'Notifications';
5
+ };
6
+ type NotificationListPhraseKey = keyof typeof notificationListPhrases;
7
+ //#endregion
8
+ export { NotificationListPhraseKey, notificationListPhrases };
9
+ //# sourceMappingURL=notification-list.phrases.d.ts.map
@@ -0,0 +1,7 @@
1
+ //#region src/notifications/notification-list.phrases.ts
2
+ /** The stack's own English, keyed `enonic.uiKit.notifications.<name>`; see `uiKitPhrases`. */
3
+ const notificationListPhrases = { "enonic.uiKit.notifications.label": "Notifications" };
4
+ //#endregion
5
+ export { notificationListPhrases };
6
+
7
+ //# sourceMappingURL=notification-list.phrases.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-list.phrases.js","names":[],"sources":["../../src/notifications/notification-list.phrases.ts"],"sourcesContent":["/** The stack's own English, keyed `enonic.uiKit.notifications.<name>`; see `uiKitPhrases`. */\nexport const notificationListPhrases = {\n 'enonic.uiKit.notifications.label': 'Notifications',\n} as const;\n\nexport type NotificationListPhraseKey = keyof typeof notificationListPhrases;\n"],"mappings":";;AACA,MAAa,0BAA0B,EACrC,oCAAoC,gBACtC"}
@@ -0,0 +1,43 @@
1
+ import { useRef } from "react";
2
+ //#region src/notifications/use-focus-hand-off.ts
3
+ function activeElementAround(element) {
4
+ return element.getRootNode().activeElement;
5
+ }
6
+ function hasFocus(element) {
7
+ return element.contains(activeElementAround(element));
8
+ }
9
+ function useFocusHandOff() {
10
+ const stack = useRef(null);
11
+ const enteredFrom = useRef(null);
12
+ const focusInside = useRef(false);
13
+ const closedByKeyboard = useRef(false);
14
+ const onFocusCapture = ({ relatedTarget }) => {
15
+ const from = relatedTarget instanceof HTMLElement ? relatedTarget : null;
16
+ if (from == null ? !focusInside.current : !stack.current?.contains(from)) enteredFrom.current = from;
17
+ focusInside.current = true;
18
+ };
19
+ const onBlurCapture = ({ relatedTarget }) => {
20
+ if (relatedTarget instanceof Node && !stack.current?.contains(relatedTarget)) focusInside.current = false;
21
+ };
22
+ const onClickCapture = ({ detail }) => {
23
+ closedByKeyboard.current = detail === 0;
24
+ };
25
+ const handOff = (wrapper) => {
26
+ if (wrapper == null || !closedByKeyboard.current || !hasFocus(wrapper)) return;
27
+ const target = (wrapper.nextElementSibling ?? wrapper.previousElementSibling)?.querySelector("button") ?? enteredFrom.current;
28
+ if (target?.isConnected) target.focus();
29
+ };
30
+ return {
31
+ stackProps: {
32
+ ref: stack,
33
+ onFocusCapture,
34
+ onBlurCapture,
35
+ onClickCapture
36
+ },
37
+ handOff
38
+ };
39
+ }
40
+ //#endregion
41
+ export { hasFocus, useFocusHandOff };
42
+
43
+ //# sourceMappingURL=use-focus-hand-off.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-focus-hand-off.js","names":[],"sources":["../../src/notifications/use-focus-hand-off.ts"],"sourcesContent":["import { type FocusEventHandler, type MouseEventHandler, type Ref, useRef } from 'react';\n\n// Inside a shadow root `document.activeElement` is the host, so the focus is read from the root\n// the element is in.\nfunction activeElementAround(element: Element): Element | null {\n return (element.getRootNode() as Document | ShadowRoot).activeElement;\n}\n\nexport function hasFocus(element: Element): boolean {\n return element.contains(activeElementAround(element));\n}\n\nexport type FocusHandOff = {\n /** Spread on the element that holds the toasts. */\n stackProps: {\n ref: Ref<HTMLDivElement>;\n onFocusCapture: FocusEventHandler<HTMLDivElement>;\n onBlurCapture: FocusEventHandler<HTMLDivElement>;\n onClickCapture: MouseEventHandler<HTMLDivElement>;\n };\n /**\n * Called before a toast's wrapper goes: a toast closed from the keyboard while it has the focus\n * hands it to the toast next to it, or back to where the focus came from when the stack was\n * entered.\n */\n handOff: (wrapper: HTMLElement | undefined) => void;\n};\n\nexport function useFocusHandOff(): FocusHandOff {\n const stack = useRef<HTMLDivElement>(null);\n const enteredFrom = useRef<HTMLElement | null>(null);\n const focusInside = useRef(false);\n // ! A pointer click focuses the button in most browsers, so the focus alone does not tell a\n // ! keyboard close from a mouse one; `detail` is 0 for a key press and an assistive-tech click.\n const closedByKeyboard = useRef(false);\n\n // A window regaining focus reports no `relatedTarget` although the focus never left the stack.\n const onFocusCapture: FocusEventHandler<HTMLDivElement> = ({ relatedTarget }) => {\n const from = relatedTarget instanceof HTMLElement ? relatedTarget : null;\n if (from == null ? !focusInside.current : !stack.current?.contains(from)) {\n enteredFrom.current = from;\n }\n focusInside.current = true;\n };\n\n const onBlurCapture: FocusEventHandler<HTMLDivElement> = ({ relatedTarget }) => {\n if (relatedTarget instanceof Node && !stack.current?.contains(relatedTarget)) {\n focusInside.current = false;\n }\n };\n\n const onClickCapture: MouseEventHandler<HTMLDivElement> = ({ detail }) => {\n closedByKeyboard.current = detail === 0;\n };\n\n const handOff = (wrapper: HTMLElement | undefined): void => {\n if (wrapper == null || !closedByKeyboard.current || !hasFocus(wrapper)) {\n return;\n }\n const neighbour = wrapper.nextElementSibling ?? wrapper.previousElementSibling;\n const target = neighbour?.querySelector('button') ?? enteredFrom.current;\n if (target?.isConnected) {\n target.focus();\n }\n };\n\n return {\n stackProps: { ref: stack, onFocusCapture, onBlurCapture, onClickCapture },\n handOff,\n };\n}\n"],"mappings":";;AAIA,SAAS,oBAAoB,SAAkC;CAC7D,OAAQ,QAAQ,YAAY,CAAC,CAA2B;AAC1D;AAEA,SAAgB,SAAS,SAA2B;CAClD,OAAO,QAAQ,SAAS,oBAAoB,OAAO,CAAC;AACtD;AAkBA,SAAgB,kBAAgC;CAC9C,MAAM,QAAQ,OAAuB,IAAI;CACzC,MAAM,cAAc,OAA2B,IAAI;CACnD,MAAM,cAAc,OAAO,KAAK;CAGhC,MAAM,mBAAmB,OAAO,KAAK;CAGrC,MAAM,kBAAqD,EAAE,oBAAoB;EAC/E,MAAM,OAAO,yBAAyB,cAAc,gBAAgB;EACpE,IAAI,QAAQ,OAAO,CAAC,YAAY,UAAU,CAAC,MAAM,SAAS,SAAS,IAAI,GACrE,YAAY,UAAU;EAExB,YAAY,UAAU;CACxB;CAEA,MAAM,iBAAoD,EAAE,oBAAoB;EAC9E,IAAI,yBAAyB,QAAQ,CAAC,MAAM,SAAS,SAAS,aAAa,GACzE,YAAY,UAAU;CAE1B;CAEA,MAAM,kBAAqD,EAAE,aAAa;EACxE,iBAAiB,UAAU,WAAW;CACxC;CAEA,MAAM,WAAW,YAA2C;EAC1D,IAAI,WAAW,QAAQ,CAAC,iBAAiB,WAAW,CAAC,SAAS,OAAO,GACnE;EAGF,MAAM,UADY,QAAQ,sBAAsB,QAAQ,uBAAA,EAC9B,cAAc,QAAQ,KAAK,YAAY;EACjE,IAAI,QAAQ,aACV,OAAO,MAAM;CAEjB;CAEA,OAAO;EACL,YAAY;GAAE,KAAK;GAAO;GAAgB;GAAe;EAAe;EACxE;CACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enonic/ui-kit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Composite React components with behaviour: the dialog shell and its confirmations, layouts, toolbars, browse screens",
5
5
  "keywords": [
6
6
  "components",
@@ -41,20 +41,20 @@
41
41
  "provenance": false
42
42
  },
43
43
  "dependencies": {
44
- "@enonic/ui-types": "^0.6.0",
45
- "@enonic/ui-utils": "^0.6.0",
46
- "lucide-react": "^1.48.0"
44
+ "@enonic/ui-types": "^0.7.0",
45
+ "@enonic/ui-utils": "^0.7.0",
46
+ "lucide-react": "^1.64.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@enonic/ui": "^1.4.0",
50
- "@radix-ui/react-slot": "^1.2.0",
49
+ "@enonic/ui": "^1.5.0",
50
+ "@radix-ui/react-slot": "^1.5.0",
51
51
  "@storybook/preact-vite": "~10.6.0",
52
- "@testing-library/preact": "~3.2.4",
53
- "@testing-library/user-event": "~14.6.7",
54
- "@types/node": "^26.6.3",
52
+ "@testing-library/preact": "~3.2.6",
53
+ "@testing-library/user-event": "~14.6.8",
54
+ "@types/node": "^26.6.6",
55
55
  "@types/react": "^19.2.18",
56
56
  "@types/react-dom": "^19.2.4",
57
- "happy-dom": "~20.14.5",
57
+ "happy-dom": "~21.0.4",
58
58
  "preact": "^10.29.8",
59
59
  "publint": "^0.3.24",
60
60
  "typescript": "^7.0.2",
@@ -62,7 +62,7 @@
62
62
  "vitest": "4.1.10"
63
63
  },
64
64
  "peerDependencies": {
65
- "@enonic/ui": "^1.4.0",
65
+ "@enonic/ui": "^1.5.0",
66
66
  "preact": ">=10.0.0",
67
67
  "react": "^19.0.0",
68
68
  "react-dom": "^19.0.0"