@enonic/ui-kit 0.5.1 → 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.
Files changed (33) hide show
  1. package/README.md +30 -7
  2. package/dist/dialog/action-dialog-context.d.ts +7 -3
  3. package/dist/dialog/action-dialog-context.js.map +1 -1
  4. package/dist/dialog/action-dialog.js +48 -8
  5. package/dist/dialog/action-dialog.js.map +1 -1
  6. package/dist/dialog/dialog-preset.js +1 -1
  7. package/dist/dialog/dialog-preset.js.map +1 -1
  8. package/dist/dialog/use-close-guard.d.ts +3 -3
  9. package/dist/dialog/use-close-guard.js +3 -3
  10. package/dist/dialog/use-close-guard.js.map +1 -1
  11. package/dist/filter/filter-fields.d.ts +1 -1
  12. package/dist/filter/filter-fields.js.map +1 -1
  13. package/dist/filter/filter-input.d.ts +2 -2
  14. package/dist/filter/filter-input.js +14 -17
  15. package/dist/filter/filter-input.js.map +1 -1
  16. package/dist/filter/filter-input.phrases.d.ts +1 -6
  17. package/dist/filter/filter-input.phrases.js +1 -6
  18. package/dist/filter/filter-input.phrases.js.map +1 -1
  19. package/dist/i18n/phrases.d.ts +27 -0
  20. package/dist/i18n/phrases.js +14 -7
  21. package/dist/i18n/phrases.js.map +1 -1
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.js +5 -2
  24. package/dist/theme/theme-switcher.d.ts +14 -0
  25. package/dist/theme/theme-switcher.js +71 -0
  26. package/dist/theme/theme-switcher.js.map +1 -0
  27. package/dist/theme/theme-switcher.phrases.d.ts +17 -0
  28. package/dist/theme/theme-switcher.phrases.js +17 -0
  29. package/dist/theme/theme-switcher.phrases.js.map +1 -0
  30. package/dist/theme/use-resolved-theme.d.ts +10 -0
  31. package/dist/theme/use-resolved-theme.js +14 -0
  32. package/dist/theme/use-resolved-theme.js.map +1 -0
  33. package/package.json +3 -2
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
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
- The dialog with an outcome and its confirmations are here, and the filter input of a browse screen;
6
- layouts, toolbars and the rest of the browse screen follow.
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.
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
@@ -56,7 +56,7 @@ tells an application which keys its bundle lacks.
56
56
 
57
57
  | Export | What it does |
58
58
  | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
- | `ActionDialog` | `Dialog` from `@enonic/ui` plus an outcome: a `Content` that holds whether the action may be taken, a `Body` that goes inert while the footer asks, a `Footer` that takes the action or asks |
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
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
61
  | `ActionDialog.Action` | a further button of the footer, enabled by the content like Confirm; `intent`, `closeOnClick` |
62
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 |
@@ -69,11 +69,14 @@ tells an application which keys its bundle lacks.
69
69
  | `uiKitPhrases`, `useUiKitPhrases()` | the catalogue, and the hook that resolves it through the application's `Translate` |
70
70
  | `useActionDialog()` | the content's state, for anything inside it that enables the action |
71
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` and a `notice`; a value with a `count`, a `group` and `disabled` |
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
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
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
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, for an application's `comparePhrases` |
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` |
77
80
 
78
81
  ## The filter
79
82
 
@@ -86,8 +89,8 @@ with `loading` says its values are still on their way. The
86
89
  dropdown opens on a click, a keystroke or an arrow, not on focus. The fields come first; picking
87
90
  one, or typing its label and a colon — or pasting `Field: value` whole — opens its values ordered by
88
91
  hits, the empty ones last and disabled, with a `No matches` row when the typed text finds none.
89
- Enter picks the highlighted value, and the values stay open so several can be ticked; a tick picked
90
- again, or the tag's cross, takes the term back. Typed text with no field entered becomes a tag on
92
+ Enter picks the highlighted value, closes the list and returns the cursor to the input; a value
93
+ picked again, or the tag's cross, takes the term back. Typed text with no field entered becomes a tag on
91
94
  Enter. Backspace in the empty input steps out of a field, or takes the last tag back; ArrowLeft at
92
95
  the start of the input walks onto the tags, where arrows move between them and Backspace or Delete
93
96
  removes one. The right-hand cross clears everything, and leaving the filter drops a field no value
@@ -105,4 +108,24 @@ const text = textOf(query); // 'alice ward'
105
108
  <FilterInput fields={fields} value={query} onChange={setQuery} placeholder="Search users" />;
106
109
  ```
107
110
 
111
+ ## The theme
112
+
113
+ The theme is the one state here that is not the caller's: XP's admin applications share it through
114
+ one stored entry, the contract `@enonic/ui-utils` keeps. So `ThemeSwitcher` takes no value and no
115
+ callback — it reads the stored theme, writes the next one on a click, and follows a switch made
116
+ by another switcher on the page or in another tab. An application only paints:
117
+
118
+ ```tsx
119
+ const theme = useResolvedTheme(); // 'light' | 'dark'
120
+
121
+ useEffect(() => {
122
+ document.documentElement.classList.toggle('dark', theme === 'dark');
123
+ }, [theme]);
124
+
125
+ <ThemeSwitcher className="ml-auto" />;
126
+ ```
127
+
128
+ Outside a render — a host's `theme`, a script with no framework — `readResolvedTheme` and
129
+ `subscribeResolvedTheme` from `@enonic/ui-utils` answer the same.
130
+
108
131
  Part of the [Enonic UI Toolkit](https://github.com/enonic/npm-enonic-ui-toolkit).
@@ -13,10 +13,14 @@ type ActionDialogContextValue = {
13
13
  lastFocusRef: MutableRefObject<HTMLElement | null>;
14
14
  };
15
15
  declare const useActionDialog: () => ActionDialogContextValue;
16
- /** Why `onOpenChange` closes: `action` when an `Action` or the footer's Confirm took the outcome. */
16
+ /**
17
+ * Why `onOpenChange` closes: `action` when an `Action` or the footer's Confirm took the outcome,
18
+ * `close` when the header's X was clicked.
19
+ */
20
+ type ActionDialogCloseReason = 'action' | 'close';
17
21
  type ActionDialogOpenChangeDetails = {
18
- reason: 'action';
22
+ reason: ActionDialogCloseReason;
19
23
  };
20
24
  //#endregion
21
- export { ActionDialogContextValue, ActionDialogOpenChangeDetails, useActionDialog };
25
+ export { ActionDialogCloseReason, ActionDialogContextValue, ActionDialogOpenChangeDetails, useActionDialog };
22
26
  //# sourceMappingURL=action-dialog-context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"action-dialog-context.js","names":[],"sources":["../../src/dialog/action-dialog-context.tsx"],"sourcesContent":["import {\n createContext,\n type MutableRefObject,\n type ReactElement,\n type ReactNode,\n useContext,\n} from 'react';\n\nexport type ActionDialogContextValue = {\n /** Whether the action may be taken: anything inside the content may decide, a gate does. */\n confirmEnabled: boolean;\n setConfirmEnabled: (next: boolean) => void;\n /** Whether the footer is asking its question, during which the body is inert. */\n asking: boolean;\n setAsking: (next: boolean) => void;\n /** The way back from the question, for `Escape`. */\n keepRef: MutableRefObject<(() => void) | undefined>;\n /** The element inside the content that last had the focus, for the way back from the question. */\n lastFocusRef: MutableRefObject<HTMLElement | null>;\n};\n\nconst ActionDialogContext = createContext<ActionDialogContextValue | undefined>(undefined);\n\nexport type ActionDialogProviderProps = {\n value: ActionDialogContextValue;\n children?: ReactNode;\n};\n\nexport const ActionDialogProvider = ({\n value,\n children,\n}: ActionDialogProviderProps): ReactElement => (\n <ActionDialogContext.Provider value={value}>{children}</ActionDialogContext.Provider>\n);\nActionDialogProvider.displayName = 'ActionDialogProvider';\n\nexport const useActionDialog = (): ActionDialogContextValue => {\n const context = useContext(ActionDialogContext);\n if (context === undefined) {\n throw new Error('useActionDialog must be used within ActionDialog.Content');\n }\n return context;\n};\n\n/** Why `onOpenChange` closes: `action` when an `Action` or the footer's Confirm took the outcome. */\nexport type ActionDialogOpenChangeDetails = { reason: 'action' };\n\nexport type ActionDialogRootContextValue = {\n /** Set by an action that closes, read by the close it causes. */\n actionCloseRef: MutableRefObject<boolean>;\n};\n\nconst ActionDialogRootContext = createContext<ActionDialogRootContextValue | undefined>(undefined);\n\nexport const ActionDialogRootProvider = ActionDialogRootContext.Provider;\n\nexport const useActionDialogRoot = (): ActionDialogRootContextValue | undefined =>\n useContext(ActionDialogRootContext);\n"],"mappings":";;;AAqBA,MAAM,sBAAsB,cAAoD,KAAA,CAAS;AAOzF,MAAa,wBAAwB,EACnC,OACA,eAEA,oBAAC,oBAAoB,UAArB;CAAqC;CAAQ;AAAuC,CAAA;AAEtF,qBAAqB,cAAc;AAEnC,MAAa,wBAAkD;CAC7D,MAAM,UAAU,WAAW,mBAAmB;CAC9C,IAAI,YAAY,KAAA,GACd,MAAM,IAAI,MAAM,0DAA0D;CAE5E,OAAO;AACT;AAUA,MAAM,0BAA0B,cAAwD,KAAA,CAAS;AAEjG,MAAa,2BAA2B,wBAAwB;AAEhE,MAAa,4BACX,WAAW,uBAAuB"}
1
+ {"version":3,"file":"action-dialog-context.js","names":[],"sources":["../../src/dialog/action-dialog-context.tsx"],"sourcesContent":["import {\n createContext,\n type MutableRefObject,\n type ReactElement,\n type ReactNode,\n useContext,\n} from 'react';\n\nexport type ActionDialogContextValue = {\n /** Whether the action may be taken: anything inside the content may decide, a gate does. */\n confirmEnabled: boolean;\n setConfirmEnabled: (next: boolean) => void;\n /** Whether the footer is asking its question, during which the body is inert. */\n asking: boolean;\n setAsking: (next: boolean) => void;\n /** The way back from the question, for `Escape`. */\n keepRef: MutableRefObject<(() => void) | undefined>;\n /** The element inside the content that last had the focus, for the way back from the question. */\n lastFocusRef: MutableRefObject<HTMLElement | null>;\n};\n\nconst ActionDialogContext = createContext<ActionDialogContextValue | undefined>(undefined);\n\nexport type ActionDialogProviderProps = {\n value: ActionDialogContextValue;\n children?: ReactNode;\n};\n\nexport const ActionDialogProvider = ({\n value,\n children,\n}: ActionDialogProviderProps): ReactElement => (\n <ActionDialogContext.Provider value={value}>{children}</ActionDialogContext.Provider>\n);\nActionDialogProvider.displayName = 'ActionDialogProvider';\n\nexport const useActionDialog = (): ActionDialogContextValue => {\n const context = useContext(ActionDialogContext);\n if (context === undefined) {\n throw new Error('useActionDialog must be used within ActionDialog.Content');\n }\n return context;\n};\n\n/**\n * Why `onOpenChange` closes: `action` when an `Action` or the footer's Confirm took the outcome,\n * `close` when the header's X was clicked.\n */\nexport type ActionDialogCloseReason = 'action' | 'close';\n\nexport type ActionDialogOpenChangeDetails = { reason: ActionDialogCloseReason };\n\nexport type ActionDialogRootContextValue = {\n /** Set by the click that closes, read by the close it causes. */\n closeReasonRef: MutableRefObject<ActionDialogCloseReason | undefined>;\n};\n\nconst ActionDialogRootContext = createContext<ActionDialogRootContextValue | undefined>(undefined);\n\nexport const ActionDialogRootProvider = ActionDialogRootContext.Provider;\n\nexport const useActionDialogRoot = (): ActionDialogRootContextValue | undefined =>\n useContext(ActionDialogRootContext);\n"],"mappings":";;;AAqBA,MAAM,sBAAsB,cAAoD,KAAA,CAAS;AAOzF,MAAa,wBAAwB,EACnC,OACA,eAEA,oBAAC,oBAAoB,UAArB;CAAqC;CAAQ;AAAuC,CAAA;AAEtF,qBAAqB,cAAc;AAEnC,MAAa,wBAAkD;CAC7D,MAAM,UAAU,WAAW,mBAAmB;CAC9C,IAAI,YAAY,KAAA,GACd,MAAM,IAAI,MAAM,0DAA0D;CAE5E,OAAO;AACT;AAeA,MAAM,0BAA0B,cAAwD,KAAA,CAAS;AAEjG,MAAa,2BAA2B,wBAAwB;AAEhE,MAAa,4BACX,WAAW,uBAAuB"}
@@ -12,16 +12,16 @@ const SIZES = {
12
12
  };
13
13
  const DANGER_BUTTON_CLASS_NAME = "bg-btn-error text-alt hover:bg-btn-error-hover active:bg-btn-error-active focus-visible:ring-error/50";
14
14
  const ActionDialogRoot = ({ onOpenChange, ...props }) => {
15
- const actionCloseRef = useRef(false);
16
- const value = useMemo(() => ({ actionCloseRef }), []);
15
+ const closeReasonRef = useRef(void 0);
16
+ const value = useMemo(() => ({ closeReasonRef }), []);
17
17
  return /* @__PURE__ */ jsx(ActionDialogRootProvider, {
18
18
  value,
19
19
  children: /* @__PURE__ */ jsx(Dialog.Root, {
20
20
  ...props,
21
21
  onOpenChange: (open) => {
22
- const byAction = actionCloseRef.current;
23
- actionCloseRef.current = false;
24
- onOpenChange?.(open, !open && byAction ? { reason: "action" } : void 0);
22
+ const reason = closeReasonRef.current;
23
+ closeReasonRef.current = void 0;
24
+ onOpenChange?.(open, !open && reason !== void 0 ? { reason } : void 0);
25
25
  }
26
26
  })
27
27
  });
@@ -29,12 +29,21 @@ const ActionDialogRoot = ({ onOpenChange, ...props }) => {
29
29
  ActionDialogRoot.displayName = "ActionDialog.Root";
30
30
  const CONTENT_NAME = "ActionDialog.Content";
31
31
  const QUESTION_NAME = "ActionDialog.Question";
32
- const ActionDialogContent = forwardRef(({ size = "default", defaultConfirmEnabled = true, className, children, onEscapeKeyDown, onFocusCapture, ...props }, ref) => {
32
+ const CLOSE_SELECTOR = "[data-component=\"Dialog.DefaultClose\"]";
33
+ /** The chrome's controls from `@enonic/ui`; the kit's own carry `keepFocus` themselves. */
34
+ const CHROME_CONTROL_SELECTOR = `${CLOSE_SELECTOR}, [data-component="Dialog.StepIndicator"]`;
35
+ const keepFocus = (event) => event.preventDefault();
36
+ /**
37
+ * The dialog's own controls take no focus from a field on a press, so Save runs with the field
38
+ * still focused: a form validated on blur must also validate on confirm.
39
+ */
40
+ const ActionDialogContent = forwardRef(({ size = "default", defaultConfirmEnabled = true, className, children, onEscapeKeyDown, onFocusCapture, onPointerDownCapture, onClickCapture, onClick, onMouseDown, ...props }, ref) => {
33
41
  const [confirmEnabled, setConfirmEnabled] = useState(defaultConfirmEnabled);
34
42
  const [asking, setAsking] = useState(false);
35
43
  const keepRef = useRef(void 0);
36
44
  const lastFocusRef = useRef(null);
37
45
  const wasAskingRef = useRef(false);
46
+ const root = useActionDialogRoot();
38
47
  const context = useMemo(() => ({
39
48
  confirmEnabled,
40
49
  setConfirmEnabled,
@@ -51,6 +60,7 @@ const ActionDialogContent = forwardRef(({ size = "default", defaultConfirmEnable
51
60
  const frame = requestAnimationFrame(() => target.focus());
52
61
  return () => cancelAnimationFrame(frame);
53
62
  }, [asking]);
63
+ const keepsEditing = (target) => asking && target instanceof Element && target.closest(`${CLOSE_SELECTOR}, [data-component="${QUESTION_NAME}"]`) === null;
54
64
  return /* @__PURE__ */ jsx(Dialog.Content, {
55
65
  ref,
56
66
  "data-component": CONTENT_NAME,
@@ -59,6 +69,25 @@ const ActionDialogContent = forwardRef(({ size = "default", defaultConfirmEnable
59
69
  onFocusCapture?.(event);
60
70
  if (event.target instanceof HTMLElement && event.target.closest(`[data-component="${QUESTION_NAME}"]`) === null) lastFocusRef.current = event.target;
61
71
  },
72
+ onPointerDownCapture: (event) => {
73
+ onPointerDownCapture?.(event);
74
+ if (keepsEditing(event.target)) {
75
+ event.preventDefault();
76
+ event.stopPropagation();
77
+ }
78
+ },
79
+ onClickCapture: (event) => {
80
+ onClickCapture?.(event);
81
+ if (keepsEditing(event.target)) {
82
+ event.preventDefault();
83
+ event.stopPropagation();
84
+ keepRef.current?.();
85
+ } else if (root !== void 0 && event.target instanceof Element && event.target.closest(CLOSE_SELECTOR) !== null) root.closeReasonRef.current = "close";
86
+ },
87
+ onClick: (event) => {
88
+ onClick?.(event);
89
+ if (root !== void 0) root.closeReasonRef.current = void 0;
90
+ },
62
91
  onEscapeKeyDown: (event) => {
63
92
  onEscapeKeyDown?.(event);
64
93
  if (asking && !event.defaultPrevented) {
@@ -66,6 +95,10 @@ const ActionDialogContent = forwardRef(({ size = "default", defaultConfirmEnable
66
95
  keepRef.current?.();
67
96
  }
68
97
  },
98
+ onMouseDown: (event) => {
99
+ onMouseDown?.(event);
100
+ if (event.target instanceof Element && event.target.closest(CHROME_CONTROL_SELECTOR) !== null) keepFocus(event);
101
+ },
69
102
  ...props,
70
103
  children: /* @__PURE__ */ jsx(ActionDialogProvider, {
71
104
  value: context,
@@ -88,7 +121,7 @@ const ActionDialogBody = forwardRef(({ className, ...props }, ref) => {
88
121
  ActionDialogBody.displayName = BODY_NAME;
89
122
  const ACTION_NAME = "ActionDialog.Action";
90
123
  /** An action of the footer: enabled by the content, red for a destructive one. */
91
- const ActionDialogAction = forwardRef(({ label, intent = "default", disabled = false, closeOnClick = true, variant = "solid", className, onClick, ...props }, ref) => {
124
+ const ActionDialogAction = forwardRef(({ label, intent = "default", disabled = false, closeOnClick = true, variant = "solid", className, onClick, onMouseDown, ...props }, ref) => {
92
125
  const { confirmEnabled } = useActionDialog();
93
126
  const root = useActionDialogRoot();
94
127
  const button = /* @__PURE__ */ jsx(Button, {
@@ -99,9 +132,13 @@ const ActionDialogAction = forwardRef(({ label, intent = "default", disabled = f
99
132
  label,
100
133
  disabled: disabled || !confirmEnabled,
101
134
  className: cn(intent === "danger" && DANGER_BUTTON_CLASS_NAME, className),
135
+ onMouseDown: (event) => {
136
+ onMouseDown?.(event);
137
+ keepFocus(event);
138
+ },
102
139
  onClick: (event) => {
103
140
  onClick?.(event);
104
- if (closeOnClick && root !== void 0 && !event.defaultPrevented) root.actionCloseRef.current = true;
141
+ if (closeOnClick && root !== void 0 && !event.defaultPrevented) root.closeReasonRef.current = "action";
105
142
  },
106
143
  ...props
107
144
  });
@@ -134,6 +171,7 @@ const ActionDialogFooter = forwardRef(({ onConfirm, onCancel, confirmLabel, canc
134
171
  variant: cancelVariant,
135
172
  size: "lg",
136
173
  label: cancelLabel ?? t("enonic.uiKit.dialog.cancel"),
174
+ onMouseDown: keepFocus,
137
175
  onClick: onCancel
138
176
  });
139
177
  return /* @__PURE__ */ jsxs(Dialog.Footer, {
@@ -157,6 +195,7 @@ const ActionDialogFooter = forwardRef(({ onConfirm, onCancel, confirmLabel, canc
157
195
  variant: "outline",
158
196
  size: "lg",
159
197
  label: question.keepLabel ?? t("enonic.uiKit.dialog.keep"),
198
+ onMouseDown: keepFocus,
160
199
  onClick: question.onKeep
161
200
  }), /* @__PURE__ */ jsx(Button, {
162
201
  "aria-describedby": questionId,
@@ -164,6 +203,7 @@ const ActionDialogFooter = forwardRef(({ onConfirm, onCancel, confirmLabel, canc
164
203
  size: "lg",
165
204
  label: question.confirmLabel ?? t("enonic.uiKit.dialog.confirm"),
166
205
  className: cn(question.intent === "danger" && DANGER_BUTTON_CLASS_NAME),
206
+ onMouseDown: keepFocus,
167
207
  onClick: question.onConfirm
168
208
  })]
169
209
  })]
@@ -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 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 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 actionCloseRef = useRef(false);\n const value = useMemo(() => ({ actionCloseRef }), []);\n\n return (\n <ActionDialogRootProvider value={value}>\n <Dialog.Root\n {...props}\n onOpenChange={(open) => {\n const byAction = actionCloseRef.current;\n actionCloseRef.current = false;\n onOpenChange?.(open, !open && byAction ? { reason: 'action' } : 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';\n\nconst ActionDialogContent = forwardRef<HTMLDivElement, ActionDialogContentProps>(\n (\n {\n size = 'default',\n defaultConfirmEnabled = true,\n className,\n children,\n onEscapeKeyDown,\n onFocusCapture,\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\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 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 onEscapeKeyDown={(event) => {\n onEscapeKeyDown?.(event);\n // While the footer asks its question, `Escape` is the way back, not the way out.\n if (asking && !event.defaultPrevented) {\n event.preventDefault();\n keepRef.current?.();\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 ...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 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.actionCloseRef.current = true;\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 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 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 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 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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"}
@@ -6,7 +6,7 @@ const CONFIRM_NAME = "DialogPreset.Confirm";
6
6
  const DialogPresetConfirm = ({ open, onOpenChange, title, description, question, intent = "default", size = "default", confirmLabel, cancelLabel, onConfirm, onCancel, error, closeOnConfirm = true, children, "data-component": componentName = CONFIRM_NAME }) => /* @__PURE__ */ jsx(ActionDialog.Root, {
7
7
  open,
8
8
  onOpenChange: (next, details) => {
9
- if (!next && details === void 0) onCancel?.();
9
+ if (!next && details?.reason !== "action") onCancel?.();
10
10
  onOpenChange(next);
11
11
  },
12
12
  children: /* @__PURE__ */ jsxs(ActionDialog.Portal, { children: [/* @__PURE__ */ jsx(ActionDialog.Overlay, {}), /* @__PURE__ */ jsxs(ActionDialog.Content, {
@@ -1 +1 @@
1
- {"version":3,"file":"dialog-preset.js","names":[],"sources":["../../src/dialog/dialog-preset.tsx"],"sourcesContent":["import type { ReactElement, ReactNode } from 'react';\n\nimport { ActionDialog, type DialogIntent, type DialogSize } from './action-dialog';\n\nexport type DialogPresetConfirmProps = {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n description?: ReactNode;\n question?: ReactNode;\n intent?: DialogIntent;\n size?: DialogSize;\n confirmLabel?: string;\n cancelLabel?: string;\n onConfirm: () => void;\n /** Cancel, `Escape`, the mask and the close button alike. */\n onCancel?: () => void;\n /** Why the dialog is still open after a confirm that did not close it. */\n error?: string;\n /** Off for a caller that closes after its own work, or reports an `error` instead. */\n closeOnConfirm?: boolean;\n children?: ReactNode;\n 'data-component'?: string;\n};\n\nconst CONFIRM_NAME = 'DialogPreset.Confirm';\n\n/** The plain question: a title, the question, and the two answers with equal weight. */\nconst DialogPresetConfirm = ({\n open,\n onOpenChange,\n title,\n description,\n question,\n intent = 'default',\n size = 'default',\n confirmLabel,\n cancelLabel,\n onConfirm,\n onCancel,\n error,\n closeOnConfirm = true,\n children,\n 'data-component': componentName = CONFIRM_NAME,\n}: DialogPresetConfirmProps): ReactElement => (\n <ActionDialog.Root\n open={open}\n onOpenChange={(next, details) => {\n if (!next && details === undefined) {\n onCancel?.();\n }\n onOpenChange(next);\n }}\n >\n <ActionDialog.Portal>\n <ActionDialog.Overlay />\n <ActionDialog.Content data-component={componentName} size={size}>\n <ActionDialog.DefaultHeader title={title} description={description} />\n {(question !== undefined || children !== undefined) && (\n <ActionDialog.Body>\n {question !== undefined && <div>{question}</div>}\n {children}\n </ActionDialog.Body>\n )}\n <ActionDialog.Footer\n intent={intent}\n cancelVariant=\"outline\"\n confirmLabel={confirmLabel}\n cancelLabel={cancelLabel}\n onConfirm={onConfirm}\n error={error}\n closeOnConfirm={closeOnConfirm}\n />\n </ActionDialog.Content>\n </ActionDialog.Portal>\n </ActionDialog.Root>\n);\nDialogPresetConfirm.displayName = CONFIRM_NAME;\n\nexport const DialogPreset = {\n Confirm: DialogPresetConfirm,\n};\n"],"mappings":";;;AAyBA,MAAM,eAAe;;AAGrB,MAAM,uBAAuB,EAC3B,MACA,cACA,OACA,aACA,UACA,SAAS,WACT,OAAO,WACP,cACA,aACA,WACA,UACA,OACA,iBAAiB,MACjB,UACA,kBAAkB,gBAAgB,mBAElC,oBAAC,aAAa,MAAd;CACQ;CACN,eAAe,MAAM,YAAY;EAC/B,IAAI,CAAC,QAAQ,YAAY,KAAA,GACvB,WAAW;EAEb,aAAa,IAAI;CACnB;CAEA,UAAA,qBAAC,aAAa,QAAd,EAAA,UAAA,CACE,oBAAC,aAAa,SAAd,CAAuB,CAAA,GACvB,qBAAC,aAAa,SAAd;EAAsB,kBAAgB;EAAqB;EAA3D,UAAA;GACE,oBAAC,aAAa,eAAd;IAAmC;IAAoB;GAAc,CAAA;IACnE,aAAa,KAAA,KAAa,aAAa,KAAA,MACvC,qBAAC,aAAa,MAAd,EAAA,UAAA,CACG,aAAa,KAAA,KAAa,oBAAC,OAAD,EAAA,UAAM,SAAc,CAAA,GAC9C,QACgB,EAAA,CAAA;GAErB,oBAAC,aAAa,QAAd;IACU;IACR,eAAc;IACA;IACD;IACF;IACJ;IACS;GACjB,CAAA;EACmB;CACH,CAAA,CAAA,EAAA,CAAA;AACJ,CAAA;AAErB,oBAAoB,cAAc;AAElC,MAAa,eAAe,EAC1B,SAAS,oBACX"}
1
+ {"version":3,"file":"dialog-preset.js","names":[],"sources":["../../src/dialog/dialog-preset.tsx"],"sourcesContent":["import type { ReactElement, ReactNode } from 'react';\n\nimport { ActionDialog, type DialogIntent, type DialogSize } from './action-dialog';\n\nexport type DialogPresetConfirmProps = {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n description?: ReactNode;\n question?: ReactNode;\n intent?: DialogIntent;\n size?: DialogSize;\n confirmLabel?: string;\n cancelLabel?: string;\n onConfirm: () => void;\n /** Cancel, `Escape`, the mask and the close button alike. */\n onCancel?: () => void;\n /** Why the dialog is still open after a confirm that did not close it. */\n error?: string;\n /** Off for a caller that closes after its own work, or reports an `error` instead. */\n closeOnConfirm?: boolean;\n children?: ReactNode;\n 'data-component'?: string;\n};\n\nconst CONFIRM_NAME = 'DialogPreset.Confirm';\n\n/** The plain question: a title, the question, and the two answers with equal weight. */\nconst DialogPresetConfirm = ({\n open,\n onOpenChange,\n title,\n description,\n question,\n intent = 'default',\n size = 'default',\n confirmLabel,\n cancelLabel,\n onConfirm,\n onCancel,\n error,\n closeOnConfirm = true,\n children,\n 'data-component': componentName = CONFIRM_NAME,\n}: DialogPresetConfirmProps): ReactElement => (\n <ActionDialog.Root\n open={open}\n onOpenChange={(next, details) => {\n if (!next && details?.reason !== 'action') {\n onCancel?.();\n }\n onOpenChange(next);\n }}\n >\n <ActionDialog.Portal>\n <ActionDialog.Overlay />\n <ActionDialog.Content data-component={componentName} size={size}>\n <ActionDialog.DefaultHeader title={title} description={description} />\n {(question !== undefined || children !== undefined) && (\n <ActionDialog.Body>\n {question !== undefined && <div>{question}</div>}\n {children}\n </ActionDialog.Body>\n )}\n <ActionDialog.Footer\n intent={intent}\n cancelVariant=\"outline\"\n confirmLabel={confirmLabel}\n cancelLabel={cancelLabel}\n onConfirm={onConfirm}\n error={error}\n closeOnConfirm={closeOnConfirm}\n />\n </ActionDialog.Content>\n </ActionDialog.Portal>\n </ActionDialog.Root>\n);\nDialogPresetConfirm.displayName = CONFIRM_NAME;\n\nexport const DialogPreset = {\n Confirm: DialogPresetConfirm,\n};\n"],"mappings":";;;AAyBA,MAAM,eAAe;;AAGrB,MAAM,uBAAuB,EAC3B,MACA,cACA,OACA,aACA,UACA,SAAS,WACT,OAAO,WACP,cACA,aACA,WACA,UACA,OACA,iBAAiB,MACjB,UACA,kBAAkB,gBAAgB,mBAElC,oBAAC,aAAa,MAAd;CACQ;CACN,eAAe,MAAM,YAAY;EAC/B,IAAI,CAAC,QAAQ,SAAS,WAAW,UAC/B,WAAW;EAEb,aAAa,IAAI;CACnB;CAEA,UAAA,qBAAC,aAAa,QAAd,EAAA,UAAA,CACE,oBAAC,aAAa,SAAd,CAAuB,CAAA,GACvB,qBAAC,aAAa,SAAd;EAAsB,kBAAgB;EAAqB;EAA3D,UAAA;GACE,oBAAC,aAAa,eAAd;IAAmC;IAAoB;GAAc,CAAA;IACnE,aAAa,KAAA,KAAa,aAAa,KAAA,MACvC,qBAAC,aAAa,MAAd,EAAA,UAAA,CACG,aAAa,KAAA,KAAa,oBAAC,OAAD,EAAA,UAAM,SAAc,CAAA,GAC9C,QACgB,EAAA,CAAA;GAErB,oBAAC,aAAa,QAAd;IACU;IACR,eAAc;IACA;IACD;IACF;IACJ;IACS;GACjB,CAAA;EACmB;CACH,CAAA,CAAA,EAAA,CAAA;AACJ,CAAA;AAErB,oBAAoB,cAAc;AAElC,MAAa,eAAe,EAC1B,SAAS,oBACX"}
@@ -11,7 +11,7 @@ type CloseGuard = {
11
11
  asking: boolean;
12
12
  /**
13
13
  * For `ActionDialog.Root`: lets an open through, and a close an action took; asks before any
14
- * other close that would lose something.
14
+ * other close that would lose something. While asking, the X closes and any other close keeps.
15
15
  */
16
16
  onOpenChange: (open: boolean, details?: ActionDialogOpenChangeDetails) => void;
17
17
  /** Asks, or closes when there is nothing to lose — a Cancel button. */
@@ -25,8 +25,8 @@ type CloseGuard = {
25
25
  * The dirty-close question of a dialog: `Escape`, the mask, the close button and Cancel all come
26
26
  * through `onOpenChange(false)`, and a dialog with unsaved edits asks before it closes. A close
27
27
  * from the footer's Confirm or an `Action` is the outcome itself and goes through unasked — its
28
- * click runs before the render that would clear `dirty`. The footer renders the question; this
29
- * decides when it is asked.
28
+ * click runs before the render that would clear `dirty`. The X clicked again while asking is the
29
+ * answer to close. The footer renders the question; this decides when it is asked.
30
30
  */
31
31
  declare function useCloseGuard({ dirty, open, onOpenChange }: CloseGuardOptions): CloseGuard;
32
32
  //#endregion
@@ -4,8 +4,8 @@ import { useEffect, useState } from "react";
4
4
  * The dirty-close question of a dialog: `Escape`, the mask, the close button and Cancel all come
5
5
  * through `onOpenChange(false)`, and a dialog with unsaved edits asks before it closes. A close
6
6
  * from the footer's Confirm or an `Action` is the outcome itself and goes through unasked — its
7
- * click runs before the render that would clear `dirty`. The footer renders the question; this
8
- * decides when it is asked.
7
+ * click runs before the render that would clear `dirty`. The X clicked again while asking is the
8
+ * answer to close. The footer renders the question; this decides when it is asked.
9
9
  */
10
10
  function useCloseGuard({ dirty, open, onOpenChange }) {
11
11
  const [asking, setAsking] = useState(false);
@@ -28,7 +28,7 @@ function useCloseGuard({ dirty, open, onOpenChange }) {
28
28
  discard,
29
29
  onOpenChange: (next, details) => {
30
30
  if (next) onOpenChange(true);
31
- else if (details?.reason === "action") discard();
31
+ else if (details?.reason === "action" || asking && details?.reason === "close") discard();
32
32
  else if (asking) keep();
33
33
  else requestClose();
34
34
  }
@@ -1 +1 @@
1
- {"version":3,"file":"use-close-guard.js","names":[],"sources":["../../src/dialog/use-close-guard.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\nimport type { ActionDialogOpenChangeDetails } from './action-dialog-context';\n\nexport type CloseGuardOptions = {\n /** Whether closing would lose something. */\n dirty: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n};\n\nexport type CloseGuard = {\n /** Whether the question is being asked. */\n asking: boolean;\n /**\n * For `ActionDialog.Root`: lets an open through, and a close an action took; asks before any\n * other close that would lose something.\n */\n onOpenChange: (open: boolean, details?: ActionDialogOpenChangeDetails) => void;\n /** Asks, or closes when there is nothing to lose — a Cancel button. */\n requestClose: () => void;\n /** The way back: the question goes, and the footer returns the focus to the control it left. */\n keep: () => void;\n /** Closes, losing what there was. */\n discard: () => void;\n};\n\n/**\n * The dirty-close question of a dialog: `Escape`, the mask, the close button and Cancel all come\n * through `onOpenChange(false)`, and a dialog with unsaved edits asks before it closes. A close\n * from the footer's Confirm or an `Action` is the outcome itself and goes through unasked — its\n * click runs before the render that would clear `dirty`. The footer renders the question; this\n * decides when it is asked.\n */\nexport function useCloseGuard({ dirty, open, onOpenChange }: CloseGuardOptions): CloseGuard {\n const [asking, setAsking] = useState(false);\n\n useEffect(() => {\n if (!open) {\n setAsking(false);\n }\n }, [open]);\n\n const requestClose = (): void => {\n if (dirty) {\n setAsking(true);\n } else {\n onOpenChange(false);\n }\n };\n const keep = (): void => setAsking(false);\n const discard = (): void => {\n setAsking(false);\n onOpenChange(false);\n };\n\n return {\n asking,\n requestClose,\n keep,\n discard,\n onOpenChange: (next, details) => {\n if (next) {\n onOpenChange(true);\n } else if (details?.reason === 'action') {\n discard();\n } else if (asking) {\n keep();\n } else {\n requestClose();\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;AAkCA,SAAgB,cAAc,EAAE,OAAO,MAAM,gBAA+C;CAC1F,MAAM,CAAC,QAAQ,aAAa,SAAS,KAAK;CAE1C,gBAAgB;EACd,IAAI,CAAC,MACH,UAAU,KAAK;CAEnB,GAAG,CAAC,IAAI,CAAC;CAET,MAAM,qBAA2B;EAC/B,IAAI,OACF,UAAU,IAAI;OAEd,aAAa,KAAK;CAEtB;CACA,MAAM,aAAmB,UAAU,KAAK;CACxC,MAAM,gBAAsB;EAC1B,UAAU,KAAK;EACf,aAAa,KAAK;CACpB;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA,eAAe,MAAM,YAAY;GAC/B,IAAI,MACF,aAAa,IAAI;QACZ,IAAI,SAAS,WAAW,UAC7B,QAAQ;QACH,IAAI,QACT,KAAK;QAEL,aAAa;EAEjB;CACF;AACF"}
1
+ {"version":3,"file":"use-close-guard.js","names":[],"sources":["../../src/dialog/use-close-guard.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\nimport type { ActionDialogOpenChangeDetails } from './action-dialog-context';\n\nexport type CloseGuardOptions = {\n /** Whether closing would lose something. */\n dirty: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n};\n\nexport type CloseGuard = {\n /** Whether the question is being asked. */\n asking: boolean;\n /**\n * For `ActionDialog.Root`: lets an open through, and a close an action took; asks before any\n * other close that would lose something. While asking, the X closes and any other close keeps.\n */\n onOpenChange: (open: boolean, details?: ActionDialogOpenChangeDetails) => void;\n /** Asks, or closes when there is nothing to lose — a Cancel button. */\n requestClose: () => void;\n /** The way back: the question goes, and the footer returns the focus to the control it left. */\n keep: () => void;\n /** Closes, losing what there was. */\n discard: () => void;\n};\n\n/**\n * The dirty-close question of a dialog: `Escape`, the mask, the close button and Cancel all come\n * through `onOpenChange(false)`, and a dialog with unsaved edits asks before it closes. A close\n * from the footer's Confirm or an `Action` is the outcome itself and goes through unasked — its\n * click runs before the render that would clear `dirty`. The X clicked again while asking is the\n * answer to close. The footer renders the question; this decides when it is asked.\n */\nexport function useCloseGuard({ dirty, open, onOpenChange }: CloseGuardOptions): CloseGuard {\n const [asking, setAsking] = useState(false);\n\n useEffect(() => {\n if (!open) {\n setAsking(false);\n }\n }, [open]);\n\n const requestClose = (): void => {\n if (dirty) {\n setAsking(true);\n } else {\n onOpenChange(false);\n }\n };\n const keep = (): void => setAsking(false);\n const discard = (): void => {\n setAsking(false);\n onOpenChange(false);\n };\n\n return {\n asking,\n requestClose,\n keep,\n discard,\n onOpenChange: (next, details) => {\n if (next) {\n onOpenChange(true);\n } else if (details?.reason === 'action' || (asking && details?.reason === 'close')) {\n discard();\n } else if (asking) {\n keep();\n } else {\n requestClose();\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;AAkCA,SAAgB,cAAc,EAAE,OAAO,MAAM,gBAA+C;CAC1F,MAAM,CAAC,QAAQ,aAAa,SAAS,KAAK;CAE1C,gBAAgB;EACd,IAAI,CAAC,MACH,UAAU,KAAK;CAEnB,GAAG,CAAC,IAAI,CAAC;CAET,MAAM,qBAA2B;EAC/B,IAAI,OACF,UAAU,IAAI;OAEd,aAAa,KAAK;CAEtB;CACA,MAAM,aAAmB,UAAU,KAAK;CACxC,MAAM,gBAAsB;EAC1B,UAAU,KAAK;EACf,aAAa,KAAK;CACpB;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA,eAAe,MAAM,YAAY;GAC/B,IAAI,MACF,aAAa,IAAI;QACZ,IAAI,SAAS,WAAW,YAAa,UAAU,SAAS,WAAW,SACxE,QAAQ;QACH,IAAI,QACT,KAAK;QAEL,aAAa;EAEjB;CACF;AACF"}
@@ -21,7 +21,7 @@ type FilterField = {
21
21
  label: string;
22
22
  icon?: LucideIcon;
23
23
  values: readonly FilterValue[];
24
- /** Whether several values can be picked at once, ORed. Off, a pick replaces the field's value. */
24
+ /** Whether several values can be picked at once, ORed; on by default. Off, a pick replaces the field's value. */
25
25
  multiple?: boolean;
26
26
  /** Shown above the values: why the list may be short or stale, such as a count request that failed. */
27
27
  notice?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"filter-fields.js","names":[],"sources":["../../src/filter/filter-fields.ts"],"sourcesContent":["import type { LucideIcon } from 'lucide-react';\n\nimport { isTextTerm, type FilterTerm } from './filter-query';\n\nexport type FilterValue = {\n id: string;\n label: string;\n /**\n * How many rows fall under this value, where that is knowable.\n *\n * ! Absent, not zero, where nothing can count: a value without a count is always offered — there is\n * ! nothing to tell it apart from an empty one.\n */\n count?: number;\n /** Values sharing a group are listed together under its label, after the ungrouped ones. */\n group?: string;\n /** Withheld for a reason other than an empty count; the caller says why, if anywhere. */\n disabled?: boolean;\n};\n\n/** A run of values the dropdown lists together: the ungrouped ones first and unlabelled, then each group. */\nexport type FilterValueGroup = {\n label?: string;\n values: readonly FilterValue[];\n};\n\n/** A field the filter offers, with the values it can take. */\nexport type FilterField = {\n id: string;\n label: string;\n icon?: LucideIcon;\n values: readonly FilterValue[];\n /** Whether several values can be picked at once, ORed. Off, a pick replaces the field's value. */\n multiple?: boolean;\n /** Shown above the values: why the list may be short or stale, such as a count request that failed. */\n notice?: string;\n /** The values are still being fetched; the dropdown says so instead of \"no matches\". */\n loading?: boolean;\n};\n\n/** A field named by what was typed, and whatever followed its label — `State: Started` pasted whole. */\nexport type TypedField = {\n field: FilterField;\n text: string;\n};\n\n/** What a tag shows for a term: the labels, falling back to the ids for a field or value no longer offered. */\nexport type TermLabel = {\n field?: string;\n value: string;\n};\n\n/** Whether a value can be picked: one with a count of zero would narrow the list to nothing. */\nexport function isOffered({ count, disabled }: FilterValue): boolean {\n return disabled !== true && (count === undefined || count > 0);\n}\n\n/**\n * The values by hits, most first, the ones with none last. A value without a count comes first: it is\n * offered whatever the rows, so it must not sink below the empty ones. Ties keep the field's own order.\n */\nexport function orderedValues(values: readonly FilterValue[]): FilterValue[] {\n return [...values].sort((a, b) => rank(b) - rank(a));\n}\n\nfunction rank({ count }: FilterValue): number {\n return count ?? Number.POSITIVE_INFINITY;\n}\n\n/**\n * The values as the dropdown lists them: the ungrouped ones first, then every group in the order it\n * first appears, each run ordered by hits on its own — a group's empty values sink to its end, not\n * to the end of the list, or the group would lose them to the one below.\n */\nexport function groupedValues(values: readonly FilterValue[]): FilterValueGroup[] {\n const runs = new Map<string | undefined, FilterValue[]>([[undefined, []]]);\n for (const value of values) {\n const run = runs.get(value.group);\n if (run === undefined) {\n runs.set(value.group, [value]);\n } else {\n run.push(value);\n }\n }\n\n return [...runs]\n .filter(([, run]) => run.length > 0)\n .map(([label, run]) => ({ label, values: orderedValues(run) }));\n}\n\n/** The fields whose label contains what was typed, case-insensitive; every field for a blank. */\nexport function matchingFields(fields: readonly FilterField[], typed: string): FilterField[] {\n const needle = typed.trim().toLowerCase();\n return needle.length === 0\n ? [...fields]\n : fields.filter(({ label }) => label.toLowerCase().includes(needle));\n}\n\nexport function matchingValues(values: readonly FilterValue[], typed: string): FilterValue[] {\n const needle = typed.trim().toLowerCase();\n return needle.length === 0\n ? [...values]\n : values.filter(({ label }) => label.toLowerCase().includes(needle));\n}\n\n/** The forms a field's label is typed in to enter it: `Label:` always, plus what the tags show, as a locale punctuates it. */\nexport type FieldPrefixes = (label: string) => readonly string[];\n\nconst ASCII_PREFIXES: FieldPrefixes = (label) => [`${label}:`];\n\n/**\n * The field a typed prefix names, case-insensitive, with the text after it: what was typed up to the\n * colon, or a whole `Label: value` pasted in. The longest prefix wins where one is a prefix of another.\n */\nexport function fieldTyped(\n fields: readonly FilterField[],\n typed: string,\n prefixesOf: FieldPrefixes = ASCII_PREFIXES,\n): TypedField | undefined {\n const text = typed.trimStart();\n const needle = text.toLowerCase();\n const matches = fields.flatMap((field) =>\n prefixesOf(field.label)\n .filter((prefix) => prefix.length > 0 && needle.startsWith(prefix.toLowerCase()))\n .map((prefix) => ({ field, prefix })),\n );\n const longest = matches.sort((a, b) => b.prefix.length - a.prefix.length)[0];\n\n return longest === undefined\n ? undefined\n : { field: longest.field, text: text.slice(longest.prefix.length).trim() };\n}\n\nexport function termLabel(term: FilterTerm, fields: readonly FilterField[]): TermLabel {\n if (isTextTerm(term)) {\n return { value: term.text };\n }\n\n const field = fields.find(({ id }) => id === term.field);\n const value = field?.values.find(({ id }) => id === term.value);\n\n return { field: field?.label ?? term.field, value: value?.label ?? term.value };\n}\n"],"mappings":";;;AAqDA,SAAgB,UAAU,EAAE,OAAO,YAAkC;CACnE,OAAO,aAAa,SAAS,UAAU,KAAA,KAAa,QAAQ;AAC9D;;;;;AAMA,SAAgB,cAAc,QAA+C;CAC3E,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,MAAM,GAAG,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC;AACrD;AAEA,SAAS,KAAK,EAAE,SAA8B;CAC5C,OAAO,SAAS,OAAO;AACzB;;;;;;AAOA,SAAgB,cAAc,QAAoD;CAChF,MAAM,uBAAO,IAAI,IAAuC,CAAC,CAAC,KAAA,GAAW,CAAC,CAAC,CAAC,CAAC;CACzE,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,MAAM,KAAK,IAAI,MAAM,KAAK;EAChC,IAAI,QAAQ,KAAA,GACV,KAAK,IAAI,MAAM,OAAO,CAAC,KAAK,CAAC;OAE7B,IAAI,KAAK,KAAK;CAElB;CAEA,OAAO,CAAC,GAAG,IAAI,CAAC,CACb,QAAQ,GAAG,SAAS,IAAI,SAAS,CAAC,CAAC,CACnC,KAAK,CAAC,OAAO,UAAU;EAAE;EAAO,QAAQ,cAAc,GAAG;CAAE,EAAE;AAClE;;AAGA,SAAgB,eAAe,QAAgC,OAA8B;CAC3F,MAAM,SAAS,MAAM,KAAK,CAAC,CAAC,YAAY;CACxC,OAAO,OAAO,WAAW,IACrB,CAAC,GAAG,MAAM,IACV,OAAO,QAAQ,EAAE,YAAY,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC;AACvE;AAEA,SAAgB,eAAe,QAAgC,OAA8B;CAC3F,MAAM,SAAS,MAAM,KAAK,CAAC,CAAC,YAAY;CACxC,OAAO,OAAO,WAAW,IACrB,CAAC,GAAG,MAAM,IACV,OAAO,QAAQ,EAAE,YAAY,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC;AACvE;AAKA,MAAM,kBAAiC,UAAU,CAAC,GAAG,MAAM,EAAE;;;;;AAM7D,SAAgB,WACd,QACA,OACA,aAA4B,gBACJ;CACxB,MAAM,OAAO,MAAM,UAAU;CAC7B,MAAM,SAAS,KAAK,YAAY;CAMhC,MAAM,UALU,OAAO,SAAS,UAC9B,WAAW,MAAM,KAAK,CAAC,CACpB,QAAQ,WAAW,OAAO,SAAS,KAAK,OAAO,WAAW,OAAO,YAAY,CAAC,CAAC,CAAC,CAChF,KAAK,YAAY;EAAE;EAAO;CAAO,EAAE,CAElB,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,OAAO,SAAS,EAAE,OAAO,MAAM,CAAC,CAAC;CAE1E,OAAO,YAAY,KAAA,IACf,KAAA,IACA;EAAE,OAAO,QAAQ;EAAO,MAAM,KAAK,MAAM,QAAQ,OAAO,MAAM,CAAC,CAAC,KAAK;CAAE;AAC7E;AAEA,SAAgB,UAAU,MAAkB,QAA2C;CACrF,IAAI,WAAW,IAAI,GACjB,OAAO,EAAE,OAAO,KAAK,KAAK;CAG5B,MAAM,QAAQ,OAAO,MAAM,EAAE,SAAS,OAAO,KAAK,KAAK;CACvD,MAAM,QAAQ,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO,KAAK,KAAK;CAE9D,OAAO;EAAE,OAAO,OAAO,SAAS,KAAK;EAAO,OAAO,OAAO,SAAS,KAAK;CAAM;AAChF"}
1
+ {"version":3,"file":"filter-fields.js","names":[],"sources":["../../src/filter/filter-fields.ts"],"sourcesContent":["import type { LucideIcon } from 'lucide-react';\n\nimport { isTextTerm, type FilterTerm } from './filter-query';\n\nexport type FilterValue = {\n id: string;\n label: string;\n /**\n * How many rows fall under this value, where that is knowable.\n *\n * ! Absent, not zero, where nothing can count: a value without a count is always offered — there is\n * ! nothing to tell it apart from an empty one.\n */\n count?: number;\n /** Values sharing a group are listed together under its label, after the ungrouped ones. */\n group?: string;\n /** Withheld for a reason other than an empty count; the caller says why, if anywhere. */\n disabled?: boolean;\n};\n\n/** A run of values the dropdown lists together: the ungrouped ones first and unlabelled, then each group. */\nexport type FilterValueGroup = {\n label?: string;\n values: readonly FilterValue[];\n};\n\n/** A field the filter offers, with the values it can take. */\nexport type FilterField = {\n id: string;\n label: string;\n icon?: LucideIcon;\n values: readonly FilterValue[];\n /** Whether several values can be picked at once, ORed; on by default. Off, a pick replaces the field's value. */\n multiple?: boolean;\n /** Shown above the values: why the list may be short or stale, such as a count request that failed. */\n notice?: string;\n /** The values are still being fetched; the dropdown says so instead of \"no matches\". */\n loading?: boolean;\n};\n\n/** A field named by what was typed, and whatever followed its label — `State: Started` pasted whole. */\nexport type TypedField = {\n field: FilterField;\n text: string;\n};\n\n/** What a tag shows for a term: the labels, falling back to the ids for a field or value no longer offered. */\nexport type TermLabel = {\n field?: string;\n value: string;\n};\n\n/** Whether a value can be picked: one with a count of zero would narrow the list to nothing. */\nexport function isOffered({ count, disabled }: FilterValue): boolean {\n return disabled !== true && (count === undefined || count > 0);\n}\n\n/**\n * The values by hits, most first, the ones with none last. A value without a count comes first: it is\n * offered whatever the rows, so it must not sink below the empty ones. Ties keep the field's own order.\n */\nexport function orderedValues(values: readonly FilterValue[]): FilterValue[] {\n return [...values].sort((a, b) => rank(b) - rank(a));\n}\n\nfunction rank({ count }: FilterValue): number {\n return count ?? Number.POSITIVE_INFINITY;\n}\n\n/**\n * The values as the dropdown lists them: the ungrouped ones first, then every group in the order it\n * first appears, each run ordered by hits on its own — a group's empty values sink to its end, not\n * to the end of the list, or the group would lose them to the one below.\n */\nexport function groupedValues(values: readonly FilterValue[]): FilterValueGroup[] {\n const runs = new Map<string | undefined, FilterValue[]>([[undefined, []]]);\n for (const value of values) {\n const run = runs.get(value.group);\n if (run === undefined) {\n runs.set(value.group, [value]);\n } else {\n run.push(value);\n }\n }\n\n return [...runs]\n .filter(([, run]) => run.length > 0)\n .map(([label, run]) => ({ label, values: orderedValues(run) }));\n}\n\n/** The fields whose label contains what was typed, case-insensitive; every field for a blank. */\nexport function matchingFields(fields: readonly FilterField[], typed: string): FilterField[] {\n const needle = typed.trim().toLowerCase();\n return needle.length === 0\n ? [...fields]\n : fields.filter(({ label }) => label.toLowerCase().includes(needle));\n}\n\nexport function matchingValues(values: readonly FilterValue[], typed: string): FilterValue[] {\n const needle = typed.trim().toLowerCase();\n return needle.length === 0\n ? [...values]\n : values.filter(({ label }) => label.toLowerCase().includes(needle));\n}\n\n/** The forms a field's label is typed in to enter it: `Label:` always, plus what the tags show, as a locale punctuates it. */\nexport type FieldPrefixes = (label: string) => readonly string[];\n\nconst ASCII_PREFIXES: FieldPrefixes = (label) => [`${label}:`];\n\n/**\n * The field a typed prefix names, case-insensitive, with the text after it: what was typed up to the\n * colon, or a whole `Label: value` pasted in. The longest prefix wins where one is a prefix of another.\n */\nexport function fieldTyped(\n fields: readonly FilterField[],\n typed: string,\n prefixesOf: FieldPrefixes = ASCII_PREFIXES,\n): TypedField | undefined {\n const text = typed.trimStart();\n const needle = text.toLowerCase();\n const matches = fields.flatMap((field) =>\n prefixesOf(field.label)\n .filter((prefix) => prefix.length > 0 && needle.startsWith(prefix.toLowerCase()))\n .map((prefix) => ({ field, prefix })),\n );\n const longest = matches.sort((a, b) => b.prefix.length - a.prefix.length)[0];\n\n return longest === undefined\n ? undefined\n : { field: longest.field, text: text.slice(longest.prefix.length).trim() };\n}\n\nexport function termLabel(term: FilterTerm, fields: readonly FilterField[]): TermLabel {\n if (isTextTerm(term)) {\n return { value: term.text };\n }\n\n const field = fields.find(({ id }) => id === term.field);\n const value = field?.values.find(({ id }) => id === term.value);\n\n return { field: field?.label ?? term.field, value: value?.label ?? term.value };\n}\n"],"mappings":";;;AAqDA,SAAgB,UAAU,EAAE,OAAO,YAAkC;CACnE,OAAO,aAAa,SAAS,UAAU,KAAA,KAAa,QAAQ;AAC9D;;;;;AAMA,SAAgB,cAAc,QAA+C;CAC3E,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,MAAM,GAAG,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC;AACrD;AAEA,SAAS,KAAK,EAAE,SAA8B;CAC5C,OAAO,SAAS,OAAO;AACzB;;;;;;AAOA,SAAgB,cAAc,QAAoD;CAChF,MAAM,uBAAO,IAAI,IAAuC,CAAC,CAAC,KAAA,GAAW,CAAC,CAAC,CAAC,CAAC;CACzE,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,MAAM,KAAK,IAAI,MAAM,KAAK;EAChC,IAAI,QAAQ,KAAA,GACV,KAAK,IAAI,MAAM,OAAO,CAAC,KAAK,CAAC;OAE7B,IAAI,KAAK,KAAK;CAElB;CAEA,OAAO,CAAC,GAAG,IAAI,CAAC,CACb,QAAQ,GAAG,SAAS,IAAI,SAAS,CAAC,CAAC,CACnC,KAAK,CAAC,OAAO,UAAU;EAAE;EAAO,QAAQ,cAAc,GAAG;CAAE,EAAE;AAClE;;AAGA,SAAgB,eAAe,QAAgC,OAA8B;CAC3F,MAAM,SAAS,MAAM,KAAK,CAAC,CAAC,YAAY;CACxC,OAAO,OAAO,WAAW,IACrB,CAAC,GAAG,MAAM,IACV,OAAO,QAAQ,EAAE,YAAY,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC;AACvE;AAEA,SAAgB,eAAe,QAAgC,OAA8B;CAC3F,MAAM,SAAS,MAAM,KAAK,CAAC,CAAC,YAAY;CACxC,OAAO,OAAO,WAAW,IACrB,CAAC,GAAG,MAAM,IACV,OAAO,QAAQ,EAAE,YAAY,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC;AACvE;AAKA,MAAM,kBAAiC,UAAU,CAAC,GAAG,MAAM,EAAE;;;;;AAM7D,SAAgB,WACd,QACA,OACA,aAA4B,gBACJ;CACxB,MAAM,OAAO,MAAM,UAAU;CAC7B,MAAM,SAAS,KAAK,YAAY;CAMhC,MAAM,UALU,OAAO,SAAS,UAC9B,WAAW,MAAM,KAAK,CAAC,CACpB,QAAQ,WAAW,OAAO,SAAS,KAAK,OAAO,WAAW,OAAO,YAAY,CAAC,CAAC,CAAC,CAChF,KAAK,YAAY;EAAE;EAAO;CAAO,EAAE,CAElB,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,OAAO,SAAS,EAAE,OAAO,MAAM,CAAC,CAAC;CAE1E,OAAO,YAAY,KAAA,IACf,KAAA,IACA;EAAE,OAAO,QAAQ;EAAO,MAAM,KAAK,MAAM,QAAQ,OAAO,MAAM,CAAC,CAAC,KAAK;CAAE;AAC7E;AAEA,SAAgB,UAAU,MAAkB,QAA2C;CACrF,IAAI,WAAW,IAAI,GACjB,OAAO,EAAE,OAAO,KAAK,KAAK;CAG5B,MAAM,QAAQ,OAAO,MAAM,EAAE,SAAS,OAAO,KAAK,KAAK;CACvD,MAAM,QAAQ,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO,KAAK,KAAK;CAE9D,OAAO;EAAE,OAAO,OAAO,SAAS,KAAK;EAAO,OAAO,OAAO,SAAS,KAAK;CAAM;AAChF"}
@@ -23,8 +23,8 @@ type FilterInputProps = {
23
23
  * The filter of a list: values of predefined fields combined with free text, every term a
24
24
  * tag. The dropdown opens on a click, a keystroke or an arrow — not on focus, so the focus the
25
25
  * library returns to the input on Escape does not reopen it. The fields come first; picking one, or
26
- * typing its label and a colon, opens its values with their hit counts, and the values stay open
27
- * across picks so several can be ticked. Enter picks the highlighted option, or adds typed text as
26
+ * typing its label and a colon, opens its values with their hit counts; picking a value closes the
27
+ * list and returns the focus to the input. Enter picks the highlighted option, or adds typed text as
28
28
  * a tag when no field is entered. The ref is the text input's.
29
29
  */
30
30
  declare const FilterInput: import("preact").FunctionalComponent<import("preact/compat").PropsWithoutRef<FilterInputProps> & {
@@ -1,6 +1,6 @@
1
+ import { filterInputPhrases } from "./filter-input.phrases.js";
1
2
  import { EMPTY_FILTER, fieldTerm, isFieldTerm, termKey, textTerm, toggledTerm, valuesOf, withTerm, withoutTerm } from "./filter-query.js";
2
3
  import { fieldTyped, groupedValues, isOffered, matchingFields, matchingValues, termLabel } from "./filter-fields.js";
3
- import { filterInputPhrases } from "./filter-input.phrases.js";
4
4
  import { forwardRef, useId, useRef, useState } from "react";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { Button, Combobox, IconButton, Listbox, Tag, cn, useCombobox, useComposedRefs, useControlledState, usePhrases } from "@enonic/ui";
@@ -13,8 +13,8 @@ const VALUE_OPTION = "value:";
13
13
  * The filter of a list: values of predefined fields combined with free text, every term a
14
14
  * tag. The dropdown opens on a click, a keystroke or an arrow — not on focus, so the focus the
15
15
  * library returns to the input on Escape does not reopen it. The fields come first; picking one, or
16
- * typing its label and a colon, opens its values with their hit counts, and the values stay open
17
- * across picks so several can be ticked. Enter picks the highlighted option, or adds typed text as
16
+ * typing its label and a colon, opens its values with their hit counts; picking a value closes the
17
+ * list and returns the focus to the input. Enter picks the highlighted option, or adds typed text as
18
18
  * a tag when no field is entered. The ref is the text input's.
19
19
  */
20
20
  const FilterInput = forwardRef(({ fields, value: controlledValue, defaultValue = EMPTY_FILTER, onChange: onValueChange, placeholder: ownPlaceholder, disabled = false, readOnly = false, error = false, className, "data-component": componentName = FILTER_INPUT_NAME }, ref) => {
@@ -22,7 +22,6 @@ const FilterInput = forwardRef(({ fields, value: controlledValue, defaultValue =
22
22
  const composedRef = useComposedRefs(ref, inputRef);
23
23
  const removeRefs = useRef([]);
24
24
  const pickedRef = useRef(false);
25
- const keyboardPickRef = useRef(false);
26
25
  const headingId = useId();
27
26
  const [value, onChange] = useControlledState(controlledValue, defaultValue, onValueChange);
28
27
  const [text, setText] = useState("");
@@ -72,7 +71,10 @@ const FilterInput = forwardRef(({ fields, value: controlledValue, defaultValue =
72
71
  const enterField = (next, typed = "") => {
73
72
  setFieldId(next?.id);
74
73
  setText(typed);
75
- if (next !== void 0) openList();
74
+ if (next !== void 0) {
75
+ setActiveOption(null);
76
+ openList();
77
+ }
76
78
  };
77
79
  const add = (term) => {
78
80
  const next = withTerm(value, term);
@@ -101,8 +103,9 @@ const FilterInput = forwardRef(({ fields, value: controlledValue, defaultValue =
101
103
  announce(t("enonic.uiKit.filterInput.replaced", nameOf(previous), nameOf(term)));
102
104
  }
103
105
  } else add(term);
104
- setActiveOption(`${VALUE_OPTION}${candidate.id}`);
105
- setText("");
106
+ setActiveOption(null);
107
+ enterField(void 0);
108
+ setOpen(false);
106
109
  };
107
110
  const handleTextChange = (typed) => {
108
111
  const named = field === void 0 ? fieldTyped(fields, typed ?? "", fieldPrefixes) : void 0;
@@ -115,20 +118,15 @@ const FilterInput = forwardRef(({ fields, value: controlledValue, defaultValue =
115
118
  };
116
119
  const handlePick = ([id]) => {
117
120
  if (id === void 0) return;
118
- const fromKeyboard = keyboardPickRef.current;
119
- keyboardPickRef.current = false;
120
- pickedRef.current = true;
121
- if (id.startsWith(FIELD_OPTION)) enterField(fields.find((candidate) => candidate.id === id.slice(6)));
122
- else if (field !== void 0) {
121
+ if (id.startsWith(FIELD_OPTION)) {
122
+ pickedRef.current = true;
123
+ enterField(fields.find((candidate) => candidate.id === id.slice(6)));
124
+ } else if (field !== void 0) {
123
125
  const candidate = field.values.find(({ id: valueId }) => valueId === id.slice(6));
124
126
  if (candidate !== void 0) pickValue(candidate);
125
- if (fromKeyboard) return;
126
127
  }
127
128
  focusInput();
128
129
  };
129
- const noteValueKeyDown = (event) => {
130
- keyboardPickRef.current = event.key === "Enter" || event.key === " ";
131
- };
132
130
  const handleOpenChange = (next) => {
133
131
  if (!next && pickedRef.current) {
134
132
  pickedRef.current = false;
@@ -354,7 +352,6 @@ const FilterInput = forwardRef(({ fields, value: controlledValue, defaultValue =
354
352
  disabled: !selected && !isOffered(candidate),
355
353
  "aria-selected": selected,
356
354
  className: "data-[active]:bg-surface-neutral-hover rounded-sm px-3 py-2",
357
- onKeyDown: noteValueKeyDown,
358
355
  children: [
359
356
  /* @__PURE__ */ jsx("span", {
360
357
  className: "grow truncate",
@@ -1 +1 @@
1
- {"version":3,"file":"filter-input.js","names":[],"sources":["../../src/filter/filter-input.tsx"],"sourcesContent":["import {\n Button,\n cn,\n Combobox,\n IconButton,\n Listbox,\n Tag,\n useCombobox,\n useComposedRefs,\n useControlledState,\n usePhrases,\n} from '@enonic/ui';\nimport { Check, ListFilter, X } from 'lucide-react';\nimport {\n forwardRef,\n type KeyboardEvent,\n type PointerEvent,\n type ReactElement,\n type Ref,\n useId,\n useRef,\n useState,\n} from 'react';\n\nimport {\n fieldTyped,\n groupedValues,\n isOffered,\n matchingFields,\n matchingValues,\n termLabel,\n type FilterField,\n type FilterValue,\n} from './filter-fields';\nimport { filterInputPhrases } from './filter-input.phrases';\nimport {\n EMPTY_FILTER,\n fieldTerm,\n isFieldTerm,\n termKey,\n textTerm,\n toggledTerm,\n valuesOf,\n withoutTerm,\n withTerm,\n type FilterQuery,\n type FilterTerm,\n} from './filter-query';\n\nexport type FilterInputProps = {\n /** What the dropdown offers. None leaves the input a free-text search. */\n fields: readonly FilterField[];\n /** The query, controlled; leave it out and the input keeps its own, starting from `defaultValue`. */\n value?: FilterQuery;\n defaultValue?: FilterQuery;\n onChange?: (query: FilterQuery) => void;\n /** What the empty input invites, named for the list: `Search users`. Defaults to the kit's \"Search\". */\n placeholder?: string;\n disabled?: boolean;\n /** The tags are the whole content: no input, no crosses, no clearing. */\n readOnly?: boolean;\n /** The box reads as invalid; what is wrong with the query is the caller's to say beside it. */\n error?: boolean;\n className?: string;\n 'data-component'?: string;\n};\n\nconst FILTER_INPUT_NAME = 'FilterInput';\n\n// One listbox holds both stages, so the ids say which kind of option was picked.\nconst FIELD_OPTION = 'field:';\nconst VALUE_OPTION = 'value:';\n\n/**\n * The filter of a list: values of predefined fields combined with free text, every term a\n * tag. The dropdown opens on a click, a keystroke or an arrow — not on focus, so the focus the\n * library returns to the input on Escape does not reopen it. The fields come first; picking one, or\n * typing its label and a colon, opens its values with their hit counts, and the values stay open\n * across picks so several can be ticked. Enter picks the highlighted option, or adds typed text as\n * a tag when no field is entered. The ref is the text input's.\n */\nconst FilterInput = forwardRef<HTMLInputElement, FilterInputProps>(\n (\n {\n fields,\n value: controlledValue,\n defaultValue = EMPTY_FILTER,\n onChange: onValueChange,\n placeholder: ownPlaceholder,\n disabled = false,\n readOnly = false,\n error = false,\n className,\n 'data-component': componentName = FILTER_INPUT_NAME,\n },\n ref,\n ): ReactElement => {\n const inputRef = useRef<HTMLInputElement>(null);\n const composedRef = useComposedRefs(ref, inputRef);\n const removeRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const pickedRef = useRef(false);\n const keyboardPickRef = useRef(false);\n const headingId = useId();\n const [value, onChange] = useControlledState(controlledValue, defaultValue, onValueChange);\n const [text, setText] = useState('');\n const [open, setOpen] = useState(false);\n const [fieldId, setFieldId] = useState<string>();\n const [activeOption, setActiveOption] = useState<string | null>(null);\n const [announcement, setAnnouncement] = useState({ text: '', key: 0 });\n\n const t = usePhrases(filterInputPhrases);\n const filterLabel = t('enonic.uiKit.filterInput.label');\n const placeholder = ownPlaceholder ?? t('enonic.uiKit.filterInput.placeholder');\n const valuePlaceholder = t('enonic.uiKit.filterInput.valuePlaceholder');\n const clearLabel = t('enonic.uiKit.filterInput.clear');\n const clearedLabel = t('enonic.uiKit.filterInput.cleared');\n const noMatchesLabel = t('enonic.uiKit.filterInput.noMatches');\n const loadingLabel = t('enonic.uiKit.filterInput.loading');\n\n const field = fields.find(({ id }) => id === fieldId);\n const stage = field === undefined ? 'fields' : 'values';\n const offeredFields = field === undefined ? matchingFields(fields, text) : [];\n const offeredGroups =\n field === undefined ? [] : groupedValues(matchingValues(field.values, text));\n const offeredValues = offeredGroups.flatMap(({ values }) => values);\n const picked = field === undefined ? undefined : valuesOf(value, field.id);\n // The values stage always has something to say — a value, \"no matches\", the field's notice.\n const shown = !readOnly && open && (stage === 'values' || offeredFields.length > 0);\n const inert = disabled || readOnly;\n const active = !inert && (value.length > 0 || text.length > 0 || field !== undefined);\n\n // ? The library highlights an option only while it has the focus, so from the input Enter would act\n // ? on nothing the user can see. The active option is held here instead: the first one the list\n // ? offers, until the user moves it — except in the fields stage with text typed, where Enter is\n // ? the search and nothing is highlighted. It stays put across a pick, so Enter again takes it back.\n const optionIds =\n field === undefined\n ? text.length === 0\n ? offeredFields.map(({ id }) => `${FIELD_OPTION}${id}`)\n : []\n : offeredValues\n .filter((candidate) => isOffered(candidate) || picked?.has(candidate.id) === true)\n .map(({ id }) => `${VALUE_OPTION}${id}`);\n const highlighted =\n activeOption !== null && optionIds.includes(activeOption)\n ? activeOption\n : (optionIds[0] ?? null);\n\n // What a field is typed as to enter it: the form the tags show, and `Label:` whatever the locale.\n const fieldPrefixes = (label: string): string[] => [\n t('enonic.uiKit.filterInput.fieldPrefix', label),\n `${label}:`,\n ];\n\n const nameOf = (term: FilterTerm): string => {\n const { field: fieldName, value: valueName } = termLabel(term, fields);\n return fieldName === undefined\n ? valueName\n : t('enonic.uiKit.filterInput.term', fieldName, valueName);\n };\n\n const focusInput = (): void => {\n inputRef.current?.focus();\n };\n\n // Keyed, so the same message twice in a row is a new node the live region speaks again.\n const announce = (text: string): void => {\n setAnnouncement((previous) => ({ text, key: previous.key + 1 }));\n };\n\n const openList = (): void => {\n setOpen(true);\n };\n\n const enterField = (next: FilterField | undefined, typed = ''): void => {\n setFieldId(next?.id);\n setText(typed);\n if (next !== undefined) {\n openList();\n }\n };\n\n // A term already held changes nothing and says nothing: `withTerm` answers the same query.\n const add = (term: FilterTerm): void => {\n const next = withTerm(value, term);\n if (next === value) {\n return;\n }\n\n onChange(next);\n announce(t('enonic.uiKit.filterInput.added', nameOf(term)));\n };\n\n const remove = (index: number): void => {\n const term = value[index];\n if (term === undefined) {\n return;\n }\n\n onChange(withoutTerm(value, index));\n announce(t('enonic.uiKit.filterInput.removed', nameOf(term)));\n };\n\n // A value already picked is picked again to take it back, the way the tag's own cross does; a\n // single-choice field swaps its value. The stage stays, so the next value is one pick away.\n const pickValue = (candidate: FilterValue): void => {\n if (field === undefined) {\n return;\n }\n\n const term = fieldTerm(field.id, candidate.id);\n if (picked?.has(candidate.id) === true) {\n onChange(toggledTerm(value, term));\n announce(t('enonic.uiKit.filterInput.removed', nameOf(term)));\n } else if (field.multiple === false) {\n const ofField = (held: FilterTerm): boolean => isFieldTerm(held) && held.field === field.id;\n const previous = value.find(ofField);\n if (previous === undefined) {\n add(term);\n } else {\n onChange([...value.filter((held) => !ofField(held)), term]);\n announce(t('enonic.uiKit.filterInput.replaced', nameOf(previous), nameOf(term)));\n }\n } else {\n add(term);\n }\n // The pick stays highlighted once the text that narrowed the list is cleared and the list\n // widens again, so Enter again takes this value back rather than the first of the wider list.\n setActiveOption(`${VALUE_OPTION}${candidate.id}`);\n setText('');\n };\n\n const handleTextChange = (typed: string | undefined): void => {\n const named =\n field === undefined ? fieldTyped(fields, typed ?? '', fieldPrefixes) : undefined;\n if (named !== undefined) {\n enterField(named.field, named.text);\n return;\n }\n\n setText(typed ?? '');\n openList();\n };\n\n const handlePick = ([id]: readonly string[]): void => {\n if (id === undefined) {\n return;\n }\n\n const fromKeyboard = keyboardPickRef.current;\n keyboardPickRef.current = false;\n pickedRef.current = true;\n if (id.startsWith(FIELD_OPTION)) {\n enterField(fields.find((candidate) => candidate.id === id.slice(FIELD_OPTION.length)));\n } else if (field !== undefined) {\n const candidate = field.values.find(\n ({ id: valueId }) => valueId === id.slice(VALUE_OPTION.length),\n );\n if (candidate !== undefined) {\n pickValue(candidate);\n }\n // A value picked from the keyboard stays focused, so the arrows go on to the next value.\n if (fromKeyboard) {\n return;\n }\n }\n // ! Synchronously, before the library's blur check runs: the option that was clicked is about to\n // ! unmount with the focus on it, and a focus that landed on the body would read as leaving.\n focusInput();\n };\n\n // Runs on the item before the listbox's own handler toggles it, so the pick knows its source.\n const noteValueKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {\n keyboardPickRef.current = event.key === 'Enter' || event.key === ' ';\n };\n\n const handleOpenChange = (next: boolean): void => {\n // ! A single-select combobox closes on a pick. Ours stays open — a pick swaps the stage, and a\n // ! value picked is one of several — so the close a pick asks for is the one to ignore.\n if (!next && pickedRef.current) {\n pickedRef.current = false;\n return;\n }\n\n // Leaving the filter abandons a field no value was picked for: a bare `Field:` prefix left in\n // the input would read as a term that narrows nothing.\n if (!next && field !== undefined) {\n enterField(undefined);\n }\n\n setOpen(next);\n };\n\n // Enter toggles the highlighted option while the list is open — the same option Enter on the\n // focused item toggles — or enters the highlighted field. Typed text with no field entered is a\n // search term. With nothing highlighted the stage stays — the \"no matches\" row says why — and a\n // closed list opens.\n const handleEnter = (listOpen: boolean): void => {\n if (field !== undefined) {\n if (!listOpen) {\n openList();\n return;\n }\n\n const candidate = offeredValues.find(({ id }) => `${VALUE_OPTION}${id}` === highlighted);\n if (candidate !== undefined) {\n pickValue(candidate);\n }\n return;\n }\n\n const term = textTerm(text);\n if (term !== undefined) {\n add(term);\n setText('');\n return;\n }\n\n const named = fields.find(({ id }) => `${FIELD_OPTION}${id}` === highlighted);\n if (listOpen && named !== undefined) {\n enterField(named);\n } else {\n openList();\n }\n };\n\n const handleBackspace = (): void => {\n if (field !== undefined) {\n enterField(undefined);\n } else if (value.length > 0) {\n remove(value.length - 1);\n }\n };\n\n const handleEscape = (): boolean => {\n if (field === undefined) {\n return false;\n }\n\n enterField(undefined);\n return true;\n };\n\n // The tags are one tab stop with the input: arrows walk the crosses, Backspace or Delete on a\n // cross takes its tag back and the focus moves one tag left, or home to the input.\n const focusTag = (index: number): boolean => {\n const cross = removeRefs.current[index];\n if (cross === null || cross === undefined) {\n return false;\n }\n\n cross.focus();\n return true;\n };\n\n const handleArrowLeft = (): boolean => focusTag(value.length - 1);\n\n const handleTagKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {\n if (event.key === 'ArrowLeft') {\n event.preventDefault();\n focusTag(index - 1);\n } else if (event.key === 'ArrowRight') {\n event.preventDefault();\n if (!focusTag(index + 1)) {\n focusInput();\n }\n } else if (event.key === 'Backspace' || event.key === 'Delete') {\n event.preventDefault();\n remove(index);\n if (!focusTag(index - 1)) {\n focusInput();\n }\n } else if (event.key === 'Escape') {\n event.preventDefault();\n focusInput();\n }\n };\n\n // A click on the box's own padding is a click on the field: the input takes the focus the body\n // would otherwise get, and the list opens.\n const handleBoxPointerDown = (event: PointerEvent<HTMLDivElement>): void => {\n if (inert || !(event.target instanceof Element) || event.target.closest('button, input')) {\n return;\n }\n\n event.preventDefault();\n focusInput();\n openList();\n };\n\n const clear = (): void => {\n if (value.length > 0) {\n onChange([]);\n }\n announce(clearedLabel);\n enterField(undefined);\n focusInput();\n };\n\n return (\n <Combobox\n open={shown}\n onOpenChange={handleOpenChange}\n value={text}\n onChange={handleTextChange}\n selection={[]}\n onSelectionChange={handlePick}\n active={highlighted}\n setActive={setActiveOption}\n contentType=\"listbox\"\n disabled={disabled}\n error={error}\n >\n {/*\n ! The box is the Content, not the Control: the library anchors its popup to the Control and\n ! gives it the Control's width, so the Control is the input alone — the popup then opens under\n ! the caret, after the tags, and takes the width of what it lists. The border, hover and focus\n ! ring the Control would have drawn move out here, to the box the user sees as the field.\n ! `data-click-outside-ignore` makes the whole box \"inside\" for the library's outside-click\n ! check, which otherwise knows only the Control: a click on a tag's cross or the Filter button\n ! would close the list and drop the field.\n */}\n <Combobox.Content\n data-component={componentName}\n data-click-outside-ignore\n onPointerDown={handleBoxPointerDown}\n className={cn(\n 'bg-surface-neutral border-bdr-subtle flex min-h-12 shrink-0 items-start gap-1.5 rounded-sm border p-1.5',\n 'hover:outline-bdr-subtle hover:outline-2',\n 'focus-within:ring-ring focus-within:ring-offset-ring-offset focus-within:ring-3 focus-within:ring-offset-3 focus-within:outline-none',\n 'transition-highlight',\n shown && 'border-bdr-strong',\n error && 'border-error focus-within:ring-error hover:outline-error',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n <Button\n variant=\"text\"\n size=\"sm\"\n startIcon={ListFilter}\n label={filterLabel}\n disabled={inert}\n onClick={() => {\n focusInput();\n openList();\n }}\n className=\"shrink-0\"\n />\n\n {/* Only this middle wraps; the button and the cross stay on the first line, at the edges. */}\n <div className=\"flex min-h-9 min-w-0 flex-1 flex-wrap content-center items-center gap-x-2 gap-y-1.5\">\n {value.map((term, index) => {\n const { field: fieldName, value: valueName } = termLabel(term, fields);\n\n return (\n <Tag key={termKey(term)} disabled={disabled}>\n {fieldName !== undefined && (\n <Tag.Prefix>{t('enonic.uiKit.filterInput.fieldPrefix', fieldName)}</Tag.Prefix>\n )}\n <Tag.Label>{valueName}</Tag.Label>\n {!readOnly && (\n <Tag.Remove\n ref={(node: HTMLButtonElement | null) => {\n removeRefs.current[index] = node;\n }}\n tabIndex={-1}\n aria-label={t('enonic.uiKit.filterInput.remove', nameOf(term))}\n disabled={disabled}\n onClick={() => {\n remove(index);\n focusInput();\n }}\n onKeyDown={handleTagKeyDown(index)}\n />\n )}\n </Tag>\n );\n })}\n\n {field !== undefined && (\n <span className=\"text-subtle shrink-0 whitespace-nowrap\">\n {t('enonic.uiKit.filterInput.fieldPrefix', field.label)}\n </span>\n )}\n\n {/* Read-only, the tags are the whole content: an input that takes nothing would be a\n focus stop with nothing to do, and a placeholder that invites a search it cannot take. */}\n {!readOnly && (\n <Combobox.Control className=\"h-auto min-h-0 min-w-40 flex-1 rounded-none border-0 bg-transparent focus-within:ring-0 focus-within:ring-offset-0\">\n <Combobox.Search className=\"h-auto bg-transparent px-1 py-0 hover:outline-none\">\n <FilterTextInput\n inputRef={composedRef}\n text={text}\n placeholder={field === undefined ? placeholder : valuePlaceholder}\n // The field the values belong to is in the name, since the prefix beside the\n // input is not associated with it.\n label={\n field === undefined\n ? placeholder\n : t('enonic.uiKit.filterInput.term', field.label, valuePlaceholder)\n }\n onOpen={openList}\n onEnter={handleEnter}\n onBackspace={handleBackspace}\n onEscape={handleEscape}\n onArrowLeft={handleArrowLeft}\n />\n </Combobox.Search>\n </Combobox.Control>\n )}\n </div>\n\n {active && (\n <IconButton\n icon={X}\n variant=\"text\"\n iconSize={28}\n iconStrokeWidth={1.25}\n title={clearLabel}\n aria-label={clearLabel}\n disabled={disabled}\n onClick={clear}\n className=\"text-subtle my-1 mr-1 size-7 shrink-0\"\n />\n )}\n\n <div aria-live=\"polite\" className=\"sr-only\">\n <span key={announcement.key}>{announcement.text}</span>\n </div>\n\n <Combobox.Portal>\n {/* ? `width: auto` undoes the Control's width the library sets inline; the list sizes to its labels. */}\n <Combobox.Popup style={{ width: 'auto' }} className=\"max-w-md min-w-72\">\n {/* Keyed by stage, so the list activates its first option again when the options swap. */}\n <Combobox.ListContent\n key={stage}\n aria-label={field === undefined ? filterLabel : field.label}\n // ? The listbox is left in single mode so Enter on a focused option picks it; what the\n // ? values stage is — several ticks at once — is said here instead.\n aria-multiselectable={\n field !== undefined && field.multiple !== false ? true : undefined\n }\n className=\"max-h-72 gap-y-0.5 overflow-y-auto p-2\"\n >\n {offeredFields.map(({ id, label, icon: Icon }) => (\n <Listbox.Item\n key={id}\n value={`${FIELD_OPTION}${id}`}\n className=\"data-[active]:bg-surface-neutral-hover gap-2.5 rounded-sm px-3 py-2\"\n >\n {Icon !== undefined && (\n <Icon\n size={16}\n strokeWidth={1.5}\n className=\"text-subtle shrink-0\"\n aria-hidden\n />\n )}\n <span className=\"truncate\">{label}</span>\n </Listbox.Item>\n ))}\n\n {field?.notice !== undefined && (\n <div role=\"presentation\" className=\"text-subtle px-3 py-2 text-xs\">\n {field.notice}\n </div>\n )}\n\n {field?.loading === true && (\n <div role=\"presentation\" aria-busy className=\"text-subtle px-3 py-2\">\n {loadingLabel}\n </div>\n )}\n\n {field !== undefined && field.loading !== true && offeredValues.length === 0 && (\n <div role=\"presentation\" className=\"text-subtle px-3 py-2\">\n {noMatchesLabel}\n </div>\n )}\n\n {offeredGroups.map(({ label: groupLabel, values }, groupIndex) => {\n const groupHeadingId =\n groupLabel === undefined ? undefined : `${headingId}-group-${groupIndex}`;\n\n return (\n <div\n key={groupLabel === undefined ? 'ungrouped' : `group:${groupLabel}`}\n role=\"group\"\n aria-labelledby={groupHeadingId}\n className=\"contents\"\n >\n {groupLabel !== undefined && (\n <div\n role=\"presentation\"\n className=\"flex w-full items-baseline gap-2.5 px-3 pt-2.5 pb-1\"\n >\n <span\n id={groupHeadingId}\n className=\"text-subtle min-w-0 truncate text-xs tracking-wider uppercase\"\n >\n {groupLabel}\n </span>\n <span className=\"border-bdr-subtle min-w-6 flex-1 border-b\" />\n </div>\n )}\n\n {values.map((candidate) => {\n const selected = picked?.has(candidate.id) === true;\n\n return (\n <Listbox.Item\n key={candidate.id}\n value={`${VALUE_OPTION}${candidate.id}`}\n // Picked or not, the value stays pickable: picking it again takes the term back.\n disabled={!selected && !isOffered(candidate)}\n // ? The listbox's own selection stays empty, so Enter keeps picking the option;\n // ? the item spreads props after its own, which is what lets the state be said here.\n aria-selected={selected}\n className=\"data-[active]:bg-surface-neutral-hover rounded-sm px-3 py-2\"\n onKeyDown={noteValueKeyDown}\n >\n <span className=\"grow truncate\">{candidate.label}</span>\n {selected && (\n <Check size={16} strokeWidth={2} className=\"shrink-0\" aria-hidden />\n )}\n {candidate.count !== undefined && (\n <span className=\"text-subtle tabular-nums\">({candidate.count})</span>\n )}\n </Listbox.Item>\n );\n })}\n </div>\n );\n })}\n </Combobox.ListContent>\n </Combobox.Popup>\n </Combobox.Portal>\n </Combobox.Content>\n </Combobox>\n );\n },\n);\n\nFilterInput.displayName = FILTER_INPUT_NAME;\n\nexport { FilterInput };\n\n//\n// * Internal\n//\n\n/**\n * Whether the key belongs to an IME composition. React keeps the flag on `nativeEvent`, Preact on\n * the event itself; 229 is the key code every browser reports for a composing key.\n */\nfunction isComposing(event: {\n isComposing?: boolean;\n keyCode?: number;\n nativeEvent?: { isComposing?: boolean; keyCode?: number };\n}): boolean {\n const native = event.nativeEvent ?? event;\n return native.isComposing === true || native.keyCode === 229;\n}\n\ntype FilterTextInputProps = {\n inputRef: Ref<HTMLInputElement>;\n text: string;\n placeholder: string;\n label: string;\n onOpen: () => void;\n /** With whether the list is open. */\n onEnter: (listOpen: boolean) => void;\n onBackspace: () => void;\n /** Answers whether it consumed the key; otherwise the library closes the dropdown. */\n onEscape: () => boolean;\n /** Answers whether a tag took the focus. */\n onArrowLeft: () => boolean;\n};\n\n/**\n * The input, with the keys a tag input adds to a combobox's: Enter picks or commits, Backspace on an\n * empty input takes the last tag back, Escape steps out of a field, ArrowLeft at the start walks into\n * the tags. Everything else — the arrows into the list, Escape closing — is the library's, reached\n * through its context. A key pressed mid-composition is the IME's.\n */\nconst FilterTextInput = ({\n inputRef,\n text,\n placeholder,\n label,\n onOpen,\n onEnter,\n onBackspace,\n onEscape,\n onArrowLeft,\n}: FilterTextInputProps): ReactElement => {\n const { keyHandler, open } = useCombobox();\n\n const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {\n if (isComposing(event)) {\n return;\n }\n\n if (event.key === 'Enter') {\n event.preventDefault();\n onEnter(open);\n return;\n }\n\n if (event.key === 'Backspace' && text.length === 0 && !event.repeat) {\n onBackspace();\n return;\n }\n\n if (event.key === 'Escape' && onEscape()) {\n event.preventDefault();\n return;\n }\n\n const atStart =\n event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;\n if (event.key === 'ArrowLeft' && atStart && onArrowLeft()) {\n event.preventDefault();\n return;\n }\n\n keyHandler(event);\n };\n\n return (\n <Combobox.Input\n ref={inputRef}\n // The library's input names itself `Search` in English; the caller's own prompt is the name here.\n aria-label={label}\n placeholder={placeholder}\n className=\"bg-transparent\"\n onClick={onOpen}\n onKeyDown={handleKeyDown}\n />\n );\n};\nFilterTextInput.displayName = 'FilterInput.TextInput';\n"],"mappings":"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"}
1
+ {"version":3,"file":"filter-input.js","names":[],"sources":["../../src/filter/filter-input.tsx"],"sourcesContent":["import {\n Button,\n cn,\n Combobox,\n IconButton,\n Listbox,\n Tag,\n useCombobox,\n useComposedRefs,\n useControlledState,\n usePhrases,\n} from '@enonic/ui';\nimport { Check, ListFilter, X } from 'lucide-react';\nimport {\n forwardRef,\n type KeyboardEvent,\n type PointerEvent,\n type ReactElement,\n type Ref,\n useId,\n useRef,\n useState,\n} from 'react';\n\nimport {\n fieldTyped,\n groupedValues,\n isOffered,\n matchingFields,\n matchingValues,\n termLabel,\n type FilterField,\n type FilterValue,\n} from './filter-fields';\nimport { filterInputPhrases } from './filter-input.phrases';\nimport {\n EMPTY_FILTER,\n fieldTerm,\n isFieldTerm,\n termKey,\n textTerm,\n toggledTerm,\n valuesOf,\n withoutTerm,\n withTerm,\n type FilterQuery,\n type FilterTerm,\n} from './filter-query';\n\nexport type FilterInputProps = {\n /** What the dropdown offers. None leaves the input a free-text search. */\n fields: readonly FilterField[];\n /** The query, controlled; leave it out and the input keeps its own, starting from `defaultValue`. */\n value?: FilterQuery;\n defaultValue?: FilterQuery;\n onChange?: (query: FilterQuery) => void;\n /** What the empty input invites, named for the list: `Search users`. Defaults to the kit's \"Search\". */\n placeholder?: string;\n disabled?: boolean;\n /** The tags are the whole content: no input, no crosses, no clearing. */\n readOnly?: boolean;\n /** The box reads as invalid; what is wrong with the query is the caller's to say beside it. */\n error?: boolean;\n className?: string;\n 'data-component'?: string;\n};\n\nconst FILTER_INPUT_NAME = 'FilterInput';\n\n// One listbox holds both stages, so the ids say which kind of option was picked.\nconst FIELD_OPTION = 'field:';\nconst VALUE_OPTION = 'value:';\n\n/**\n * The filter of a list: values of predefined fields combined with free text, every term a\n * tag. The dropdown opens on a click, a keystroke or an arrow — not on focus, so the focus the\n * library returns to the input on Escape does not reopen it. The fields come first; picking one, or\n * typing its label and a colon, opens its values with their hit counts; picking a value closes the\n * list and returns the focus to the input. Enter picks the highlighted option, or adds typed text as\n * a tag when no field is entered. The ref is the text input's.\n */\nconst FilterInput = forwardRef<HTMLInputElement, FilterInputProps>(\n (\n {\n fields,\n value: controlledValue,\n defaultValue = EMPTY_FILTER,\n onChange: onValueChange,\n placeholder: ownPlaceholder,\n disabled = false,\n readOnly = false,\n error = false,\n className,\n 'data-component': componentName = FILTER_INPUT_NAME,\n },\n ref,\n ): ReactElement => {\n const inputRef = useRef<HTMLInputElement>(null);\n const composedRef = useComposedRefs(ref, inputRef);\n const removeRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const pickedRef = useRef(false);\n const headingId = useId();\n const [value, onChange] = useControlledState(controlledValue, defaultValue, onValueChange);\n const [text, setText] = useState('');\n const [open, setOpen] = useState(false);\n const [fieldId, setFieldId] = useState<string>();\n const [activeOption, setActiveOption] = useState<string | null>(null);\n const [announcement, setAnnouncement] = useState({ text: '', key: 0 });\n\n const t = usePhrases(filterInputPhrases);\n const filterLabel = t('enonic.uiKit.filterInput.label');\n const placeholder = ownPlaceholder ?? t('enonic.uiKit.filterInput.placeholder');\n const valuePlaceholder = t('enonic.uiKit.filterInput.valuePlaceholder');\n const clearLabel = t('enonic.uiKit.filterInput.clear');\n const clearedLabel = t('enonic.uiKit.filterInput.cleared');\n const noMatchesLabel = t('enonic.uiKit.filterInput.noMatches');\n const loadingLabel = t('enonic.uiKit.filterInput.loading');\n\n const field = fields.find(({ id }) => id === fieldId);\n const stage = field === undefined ? 'fields' : 'values';\n const offeredFields = field === undefined ? matchingFields(fields, text) : [];\n const offeredGroups =\n field === undefined ? [] : groupedValues(matchingValues(field.values, text));\n const offeredValues = offeredGroups.flatMap(({ values }) => values);\n const picked = field === undefined ? undefined : valuesOf(value, field.id);\n // The values stage always has something to say — a value, \"no matches\", the field's notice.\n const shown = !readOnly && open && (stage === 'values' || offeredFields.length > 0);\n const inert = disabled || readOnly;\n const active = !inert && (value.length > 0 || text.length > 0 || field !== undefined);\n\n // ? The library highlights an option only while it has the focus, so from the input Enter would act\n // ? on nothing the user can see. The active option is held here instead: the first one the list\n // ? offers, until the user moves it — except in the fields stage with text typed, where Enter is\n // ? the search and nothing is highlighted.\n const optionIds =\n field === undefined\n ? text.length === 0\n ? offeredFields.map(({ id }) => `${FIELD_OPTION}${id}`)\n : []\n : offeredValues\n .filter((candidate) => isOffered(candidate) || picked?.has(candidate.id) === true)\n .map(({ id }) => `${VALUE_OPTION}${id}`);\n const highlighted =\n activeOption !== null && optionIds.includes(activeOption)\n ? activeOption\n : (optionIds[0] ?? null);\n\n const fieldPrefixes = (label: string): string[] => [\n t('enonic.uiKit.filterInput.fieldPrefix', label),\n `${label}:`,\n ];\n\n const nameOf = (term: FilterTerm): string => {\n const { field: fieldName, value: valueName } = termLabel(term, fields);\n return fieldName === undefined\n ? valueName\n : t('enonic.uiKit.filterInput.term', fieldName, valueName);\n };\n\n const focusInput = (): void => {\n inputRef.current?.focus();\n };\n\n // Keyed, so the same message twice in a row is a new node the live region speaks again.\n const announce = (text: string): void => {\n setAnnouncement((previous) => ({ text, key: previous.key + 1 }));\n };\n\n const openList = (): void => {\n setOpen(true);\n };\n\n const enterField = (next: FilterField | undefined, typed = ''): void => {\n setFieldId(next?.id);\n setText(typed);\n if (next !== undefined) {\n setActiveOption(null);\n openList();\n }\n };\n\n const add = (term: FilterTerm): void => {\n const next = withTerm(value, term);\n if (next === value) {\n return;\n }\n\n onChange(next);\n announce(t('enonic.uiKit.filterInput.added', nameOf(term)));\n };\n\n const remove = (index: number): void => {\n const term = value[index];\n if (term === undefined) {\n return;\n }\n\n onChange(withoutTerm(value, index));\n announce(t('enonic.uiKit.filterInput.removed', nameOf(term)));\n };\n\n // A value already picked is picked again to take it back, the way the tag's own cross does; a\n // single-choice field swaps its value. Either way the pick leaves the field and closes the list.\n const pickValue = (candidate: FilterValue): void => {\n if (field === undefined) {\n return;\n }\n\n const term = fieldTerm(field.id, candidate.id);\n if (picked?.has(candidate.id) === true) {\n onChange(toggledTerm(value, term));\n announce(t('enonic.uiKit.filterInput.removed', nameOf(term)));\n } else if (field.multiple === false) {\n const ofField = (held: FilterTerm): boolean => isFieldTerm(held) && held.field === field.id;\n const previous = value.find(ofField);\n if (previous === undefined) {\n add(term);\n } else {\n onChange([...value.filter((held) => !ofField(held)), term]);\n announce(t('enonic.uiKit.filterInput.replaced', nameOf(previous), nameOf(term)));\n }\n } else {\n add(term);\n }\n setActiveOption(null);\n enterField(undefined);\n setOpen(false);\n };\n\n const handleTextChange = (typed: string | undefined): void => {\n const named =\n field === undefined ? fieldTyped(fields, typed ?? '', fieldPrefixes) : undefined;\n if (named !== undefined) {\n enterField(named.field, named.text);\n return;\n }\n\n setText(typed ?? '');\n openList();\n };\n\n const handlePick = ([id]: readonly string[]): void => {\n if (id === undefined) {\n return;\n }\n\n if (id.startsWith(FIELD_OPTION)) {\n pickedRef.current = true;\n enterField(fields.find((candidate) => candidate.id === id.slice(FIELD_OPTION.length)));\n } else if (field !== undefined) {\n const candidate = field.values.find(\n ({ id: valueId }) => valueId === id.slice(VALUE_OPTION.length),\n );\n if (candidate !== undefined) {\n pickValue(candidate);\n }\n }\n // ! Synchronously, before the library's blur check runs: the option that was clicked is about to\n // ! unmount with the focus on it, and a focus that landed on the body would read as leaving.\n focusInput();\n };\n\n const handleOpenChange = (next: boolean): void => {\n // ! A single-select combobox closes on a pick. Picking a field swaps the stage instead, so the\n // ! close that pick asks for is the one to ignore.\n if (!next && pickedRef.current) {\n pickedRef.current = false;\n return;\n }\n\n // Leaving the filter abandons a field no value was picked for: a bare `Field:` prefix left in\n // the input would read as a term that narrows nothing.\n if (!next && field !== undefined) {\n enterField(undefined);\n }\n\n setOpen(next);\n };\n\n // Enter toggles the highlighted option while the list is open — the same option Enter on the\n // focused item toggles — or enters the highlighted field. Typed text with no field entered is a\n // search term. With nothing highlighted the stage stays — the \"no matches\" row says why — and a\n // closed list opens.\n const handleEnter = (listOpen: boolean): void => {\n if (field !== undefined) {\n if (!listOpen) {\n openList();\n return;\n }\n\n const candidate = offeredValues.find(({ id }) => `${VALUE_OPTION}${id}` === highlighted);\n if (candidate !== undefined) {\n pickValue(candidate);\n }\n return;\n }\n\n const term = textTerm(text);\n if (term !== undefined) {\n add(term);\n setText('');\n return;\n }\n\n const named = fields.find(({ id }) => `${FIELD_OPTION}${id}` === highlighted);\n if (listOpen && named !== undefined) {\n enterField(named);\n } else {\n openList();\n }\n };\n\n const handleBackspace = (): void => {\n if (field !== undefined) {\n enterField(undefined);\n } else if (value.length > 0) {\n remove(value.length - 1);\n }\n };\n\n const handleEscape = (): boolean => {\n if (field === undefined) {\n return false;\n }\n\n enterField(undefined);\n return true;\n };\n\n // The tags are one tab stop with the input: arrows walk the crosses, Backspace or Delete on a\n // cross takes its tag back and the focus moves one tag left, or home to the input.\n const focusTag = (index: number): boolean => {\n const cross = removeRefs.current[index];\n if (cross === null || cross === undefined) {\n return false;\n }\n\n cross.focus();\n return true;\n };\n\n const handleArrowLeft = (): boolean => focusTag(value.length - 1);\n\n const handleTagKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {\n if (event.key === 'ArrowLeft') {\n event.preventDefault();\n focusTag(index - 1);\n } else if (event.key === 'ArrowRight') {\n event.preventDefault();\n if (!focusTag(index + 1)) {\n focusInput();\n }\n } else if (event.key === 'Backspace' || event.key === 'Delete') {\n event.preventDefault();\n remove(index);\n if (!focusTag(index - 1)) {\n focusInput();\n }\n } else if (event.key === 'Escape') {\n event.preventDefault();\n focusInput();\n }\n };\n\n // A click on the box's own padding is a click on the field: the input takes the focus the body\n // would otherwise get, and the list opens.\n const handleBoxPointerDown = (event: PointerEvent<HTMLDivElement>): void => {\n if (inert || !(event.target instanceof Element) || event.target.closest('button, input')) {\n return;\n }\n\n event.preventDefault();\n focusInput();\n openList();\n };\n\n const clear = (): void => {\n if (value.length > 0) {\n onChange([]);\n }\n announce(clearedLabel);\n enterField(undefined);\n focusInput();\n };\n\n return (\n <Combobox\n open={shown}\n onOpenChange={handleOpenChange}\n value={text}\n onChange={handleTextChange}\n selection={[]}\n onSelectionChange={handlePick}\n active={highlighted}\n setActive={setActiveOption}\n contentType=\"listbox\"\n disabled={disabled}\n error={error}\n >\n {/*\n ! The box is the Content, not the Control: the library anchors its popup to the Control and\n ! gives it the Control's width, so the Control is the input alone — the popup then opens under\n ! the caret, after the tags, and takes the width of what it lists. The border, hover and focus\n ! ring the Control would have drawn move out here, to the box the user sees as the field.\n ! `data-click-outside-ignore` makes the whole box \"inside\" for the library's outside-click\n ! check, which otherwise knows only the Control: a click on a tag's cross or the Filter button\n ! would close the list and drop the field.\n */}\n <Combobox.Content\n data-component={componentName}\n data-click-outside-ignore\n onPointerDown={handleBoxPointerDown}\n className={cn(\n 'bg-surface-neutral border-bdr-subtle flex min-h-12 shrink-0 items-start gap-1.5 rounded-sm border p-1.5',\n 'hover:outline-bdr-subtle hover:outline-2',\n 'focus-within:ring-ring focus-within:ring-offset-ring-offset focus-within:ring-3 focus-within:ring-offset-3 focus-within:outline-none',\n 'transition-highlight',\n shown && 'border-bdr-strong',\n error && 'border-error focus-within:ring-error hover:outline-error',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n <Button\n variant=\"text\"\n size=\"sm\"\n startIcon={ListFilter}\n label={filterLabel}\n disabled={inert}\n onClick={() => {\n focusInput();\n openList();\n }}\n className=\"shrink-0\"\n />\n\n {/* Only this middle wraps; the button and the cross stay on the first line, at the edges. */}\n <div className=\"flex min-h-9 min-w-0 flex-1 flex-wrap content-center items-center gap-x-2 gap-y-1.5\">\n {value.map((term, index) => {\n const { field: fieldName, value: valueName } = termLabel(term, fields);\n\n return (\n <Tag key={termKey(term)} disabled={disabled}>\n {fieldName !== undefined && (\n <Tag.Prefix>{t('enonic.uiKit.filterInput.fieldPrefix', fieldName)}</Tag.Prefix>\n )}\n <Tag.Label>{valueName}</Tag.Label>\n {!readOnly && (\n <Tag.Remove\n ref={(node: HTMLButtonElement | null) => {\n removeRefs.current[index] = node;\n }}\n tabIndex={-1}\n aria-label={t('enonic.uiKit.filterInput.remove', nameOf(term))}\n disabled={disabled}\n onClick={() => {\n remove(index);\n focusInput();\n }}\n onKeyDown={handleTagKeyDown(index)}\n />\n )}\n </Tag>\n );\n })}\n\n {field !== undefined && (\n <span className=\"text-subtle shrink-0 whitespace-nowrap\">\n {t('enonic.uiKit.filterInput.fieldPrefix', field.label)}\n </span>\n )}\n\n {/* Read-only, the tags are the whole content: an input that takes nothing would be a\n focus stop with nothing to do, and a placeholder that invites a search it cannot take. */}\n {!readOnly && (\n <Combobox.Control className=\"h-auto min-h-0 min-w-40 flex-1 rounded-none border-0 bg-transparent focus-within:ring-0 focus-within:ring-offset-0\">\n <Combobox.Search className=\"h-auto bg-transparent px-1 py-0 hover:outline-none\">\n <FilterTextInput\n inputRef={composedRef}\n text={text}\n placeholder={field === undefined ? placeholder : valuePlaceholder}\n // The field the values belong to is in the name, since the prefix beside the\n // input is not associated with it.\n label={\n field === undefined\n ? placeholder\n : t('enonic.uiKit.filterInput.term', field.label, valuePlaceholder)\n }\n onOpen={openList}\n onEnter={handleEnter}\n onBackspace={handleBackspace}\n onEscape={handleEscape}\n onArrowLeft={handleArrowLeft}\n />\n </Combobox.Search>\n </Combobox.Control>\n )}\n </div>\n\n {active && (\n <IconButton\n icon={X}\n variant=\"text\"\n iconSize={28}\n iconStrokeWidth={1.25}\n title={clearLabel}\n aria-label={clearLabel}\n disabled={disabled}\n onClick={clear}\n className=\"text-subtle my-1 mr-1 size-7 shrink-0\"\n />\n )}\n\n <div aria-live=\"polite\" className=\"sr-only\">\n <span key={announcement.key}>{announcement.text}</span>\n </div>\n\n <Combobox.Portal>\n {/* ? `width: auto` undoes the Control's width the library sets inline; the list sizes to its labels. */}\n <Combobox.Popup style={{ width: 'auto' }} className=\"max-w-md min-w-72\">\n {/* Keyed by stage, so the list activates its first option again when the options swap. */}\n <Combobox.ListContent\n key={stage}\n aria-label={field === undefined ? filterLabel : field.label}\n // ? The listbox is left in single mode so Enter on a focused option picks it; what the\n // ? values stage is — several ticks at once — is said here instead.\n aria-multiselectable={\n field !== undefined && field.multiple !== false ? true : undefined\n }\n className=\"max-h-72 gap-y-0.5 overflow-y-auto p-2\"\n >\n {offeredFields.map(({ id, label, icon: Icon }) => (\n <Listbox.Item\n key={id}\n value={`${FIELD_OPTION}${id}`}\n className=\"data-[active]:bg-surface-neutral-hover gap-2.5 rounded-sm px-3 py-2\"\n >\n {Icon !== undefined && (\n <Icon\n size={16}\n strokeWidth={1.5}\n className=\"text-subtle shrink-0\"\n aria-hidden\n />\n )}\n <span className=\"truncate\">{label}</span>\n </Listbox.Item>\n ))}\n\n {field?.notice !== undefined && (\n <div role=\"presentation\" className=\"text-subtle px-3 py-2 text-xs\">\n {field.notice}\n </div>\n )}\n\n {field?.loading === true && (\n <div role=\"presentation\" aria-busy className=\"text-subtle px-3 py-2\">\n {loadingLabel}\n </div>\n )}\n\n {field !== undefined && field.loading !== true && offeredValues.length === 0 && (\n <div role=\"presentation\" className=\"text-subtle px-3 py-2\">\n {noMatchesLabel}\n </div>\n )}\n\n {offeredGroups.map(({ label: groupLabel, values }, groupIndex) => {\n const groupHeadingId =\n groupLabel === undefined ? undefined : `${headingId}-group-${groupIndex}`;\n\n return (\n <div\n key={groupLabel === undefined ? 'ungrouped' : `group:${groupLabel}`}\n role=\"group\"\n aria-labelledby={groupHeadingId}\n className=\"contents\"\n >\n {groupLabel !== undefined && (\n <div\n role=\"presentation\"\n className=\"flex w-full items-baseline gap-2.5 px-3 pt-2.5 pb-1\"\n >\n <span\n id={groupHeadingId}\n className=\"text-subtle min-w-0 truncate text-xs tracking-wider uppercase\"\n >\n {groupLabel}\n </span>\n <span className=\"border-bdr-subtle min-w-6 flex-1 border-b\" />\n </div>\n )}\n\n {values.map((candidate) => {\n const selected = picked?.has(candidate.id) === true;\n\n return (\n <Listbox.Item\n key={candidate.id}\n value={`${VALUE_OPTION}${candidate.id}`}\n // Picked or not, the value stays pickable: picking it again takes the term back.\n disabled={!selected && !isOffered(candidate)}\n // ? The listbox's own selection stays empty, so Enter keeps picking the option;\n // ? the item spreads props after its own, which is what lets the state be said here.\n aria-selected={selected}\n className=\"data-[active]:bg-surface-neutral-hover rounded-sm px-3 py-2\"\n >\n <span className=\"grow truncate\">{candidate.label}</span>\n {selected && (\n <Check size={16} strokeWidth={2} className=\"shrink-0\" aria-hidden />\n )}\n {candidate.count !== undefined && (\n <span className=\"text-subtle tabular-nums\">({candidate.count})</span>\n )}\n </Listbox.Item>\n );\n })}\n </div>\n );\n })}\n </Combobox.ListContent>\n </Combobox.Popup>\n </Combobox.Portal>\n </Combobox.Content>\n </Combobox>\n );\n },\n);\n\nFilterInput.displayName = FILTER_INPUT_NAME;\n\nexport { FilterInput };\n\n//\n// * Internal\n//\n\n/**\n * Whether the key belongs to an IME composition. React keeps the flag on `nativeEvent`, Preact on\n * the event itself; 229 is the key code every browser reports for a composing key.\n */\nfunction isComposing(event: {\n isComposing?: boolean;\n keyCode?: number;\n nativeEvent?: { isComposing?: boolean; keyCode?: number };\n}): boolean {\n const native = event.nativeEvent ?? event;\n return native.isComposing === true || native.keyCode === 229;\n}\n\ntype FilterTextInputProps = {\n inputRef: Ref<HTMLInputElement>;\n text: string;\n placeholder: string;\n label: string;\n onOpen: () => void;\n /** With whether the list is open. */\n onEnter: (listOpen: boolean) => void;\n onBackspace: () => void;\n /** Answers whether it consumed the key; otherwise the library closes the dropdown. */\n onEscape: () => boolean;\n /** Answers whether a tag took the focus. */\n onArrowLeft: () => boolean;\n};\n\n/**\n * The input, with the keys a tag input adds to a combobox's: Enter picks or commits, Backspace on an\n * empty input takes the last tag back, Escape steps out of a field, ArrowLeft at the start walks into\n * the tags. Everything else — the arrows into the list, Escape closing — is the library's, reached\n * through its context. A key pressed mid-composition is the IME's.\n */\nconst FilterTextInput = ({\n inputRef,\n text,\n placeholder,\n label,\n onOpen,\n onEnter,\n onBackspace,\n onEscape,\n onArrowLeft,\n}: FilterTextInputProps): ReactElement => {\n const { keyHandler, open } = useCombobox();\n\n const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {\n if (isComposing(event)) {\n return;\n }\n\n if (event.key === 'Enter') {\n event.preventDefault();\n onEnter(open);\n return;\n }\n\n if (event.key === 'Backspace' && text.length === 0 && !event.repeat) {\n onBackspace();\n return;\n }\n\n if (event.key === 'Escape' && onEscape()) {\n event.preventDefault();\n return;\n }\n\n const atStart =\n event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;\n if (event.key === 'ArrowLeft' && atStart && onArrowLeft()) {\n event.preventDefault();\n return;\n }\n\n keyHandler(event);\n };\n\n return (\n <Combobox.Input\n ref={inputRef}\n // The library's input names itself `Search` in English; the caller's own prompt is the name here.\n aria-label={label}\n placeholder={placeholder}\n className=\"bg-transparent\"\n onClick={onOpen}\n onKeyDown={handleKeyDown}\n />\n );\n};\nFilterTextInput.displayName = 'FilterInput.TextInput';\n"],"mappings":"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"}
@@ -1,10 +1,5 @@
1
1
  //#region src/filter/filter-input.phrases.d.ts
2
- /**
3
- * The input's own English, keyed `enonic.uiKit.filterInput.<name>`. An application translates it
4
- * through the `Translate` it hands `@enonic/ui`'s `I18nProvider`; a key it has no text for renders
5
- * this English. `comparePhrases` from `@enonic/ui-utils` tells an application which keys its
6
- * bundle lacks.
7
- */
2
+ /** The input's own English, keyed `enonic.uiKit.filterInput.<name>`; see `uiKitPhrases`. */
8
3
  declare const filterInputPhrases: {
9
4
  readonly 'enonic.uiKit.filterInput.label': 'Filter';
10
5
  readonly 'enonic.uiKit.filterInput.placeholder': 'Search';
@@ -1,10 +1,5 @@
1
1
  //#region src/filter/filter-input.phrases.ts
2
- /**
3
- * The input's own English, keyed `enonic.uiKit.filterInput.<name>`. An application translates it
4
- * through the `Translate` it hands `@enonic/ui`'s `I18nProvider`; a key it has no text for renders
5
- * this English. `comparePhrases` from `@enonic/ui-utils` tells an application which keys its
6
- * bundle lacks.
7
- */
2
+ /** The input's own English, keyed `enonic.uiKit.filterInput.<name>`; see `uiKitPhrases`. */
8
3
  const filterInputPhrases = {
9
4
  "enonic.uiKit.filterInput.label": "Filter",
10
5
  "enonic.uiKit.filterInput.placeholder": "Search",
@@ -1 +1 @@
1
- {"version":3,"file":"filter-input.phrases.js","names":[],"sources":["../../src/filter/filter-input.phrases.ts"],"sourcesContent":["/**\n * The input's own English, keyed `enonic.uiKit.filterInput.<name>`. An application translates it\n * through the `Translate` it hands `@enonic/ui`'s `I18nProvider`; a key it has no text for renders\n * this English. `comparePhrases` from `@enonic/ui-utils` tells an application which keys its\n * bundle lacks.\n */\nexport const filterInputPhrases = {\n 'enonic.uiKit.filterInput.label': 'Filter',\n 'enonic.uiKit.filterInput.placeholder': 'Search',\n 'enonic.uiKit.filterInput.valuePlaceholder': 'Pick a value',\n 'enonic.uiKit.filterInput.clear': 'Clear the filter',\n 'enonic.uiKit.filterInput.cleared': 'Filter cleared',\n 'enonic.uiKit.filterInput.noMatches': 'No matches',\n 'enonic.uiKit.filterInput.loading': 'Loading…',\n 'enonic.uiKit.filterInput.fieldPrefix': '{0}:',\n 'enonic.uiKit.filterInput.term': '{0}: {1}',\n 'enonic.uiKit.filterInput.remove': 'Remove {0}',\n 'enonic.uiKit.filterInput.added': 'Added {0}',\n 'enonic.uiKit.filterInput.removed': 'Removed {0}',\n 'enonic.uiKit.filterInput.replaced': 'Replaced {0} with {1}',\n} as const;\n\nexport type FilterInputPhraseKey = keyof typeof filterInputPhrases;\n"],"mappings":";;;;;;;AAMA,MAAa,qBAAqB;CAChC,kCAAkC;CAClC,wCAAwC;CACxC,6CAA6C;CAC7C,kCAAkC;CAClC,oCAAoC;CACpC,sCAAsC;CACtC,oCAAoC;CACpC,wCAAwC;CACxC,iCAAiC;CACjC,mCAAmC;CACnC,kCAAkC;CAClC,oCAAoC;CACpC,qCAAqC;AACvC"}
1
+ {"version":3,"file":"filter-input.phrases.js","names":[],"sources":["../../src/filter/filter-input.phrases.ts"],"sourcesContent":["/** The input's own English, keyed `enonic.uiKit.filterInput.<name>`; see `uiKitPhrases`. */\nexport const filterInputPhrases = {\n 'enonic.uiKit.filterInput.label': 'Filter',\n 'enonic.uiKit.filterInput.placeholder': 'Search',\n 'enonic.uiKit.filterInput.valuePlaceholder': 'Pick a value',\n 'enonic.uiKit.filterInput.clear': 'Clear the filter',\n 'enonic.uiKit.filterInput.cleared': 'Filter cleared',\n 'enonic.uiKit.filterInput.noMatches': 'No matches',\n 'enonic.uiKit.filterInput.loading': 'Loading…',\n 'enonic.uiKit.filterInput.fieldPrefix': '{0}:',\n 'enonic.uiKit.filterInput.term': '{0}: {1}',\n 'enonic.uiKit.filterInput.remove': 'Remove {0}',\n 'enonic.uiKit.filterInput.added': 'Added {0}',\n 'enonic.uiKit.filterInput.removed': 'Removed {0}',\n 'enonic.uiKit.filterInput.replaced': 'Replaced {0} with {1}',\n} as const;\n\nexport type FilterInputPhraseKey = keyof typeof filterInputPhrases;\n"],"mappings":";;AACA,MAAa,qBAAqB;CAChC,kCAAkC;CAClC,wCAAwC;CACxC,6CAA6C;CAC7C,kCAAkC;CAClC,oCAAoC;CACpC,sCAAsC;CACtC,oCAAoC;CACpC,wCAAwC;CACxC,iCAAiC;CACjC,mCAAmC;CACnC,kCAAkC;CAClC,oCAAoC;CACpC,qCAAqC;AACvC"}
@@ -1,5 +1,32 @@
1
1
  //#region src/i18n/phrases.d.ts
2
+ /**
3
+ * Every text the package renders, in English, keyed `enonic.uiKit.<area>.<name>`. An application
4
+ * translates them through the `Translate` it hands `@enonic/ui`'s `I18nProvider`; a key it has no
5
+ * text for renders this English. `comparePhrases` from `@enonic/ui-utils` tells an application
6
+ * which of these its bundle lacks.
7
+ */
2
8
  declare const uiKitPhrases: Readonly<{
9
+ readonly 'enonic.uiKit.filterInput.label': 'Filter';
10
+ readonly 'enonic.uiKit.filterInput.placeholder': 'Search';
11
+ readonly 'enonic.uiKit.filterInput.valuePlaceholder': 'Pick a value';
12
+ readonly 'enonic.uiKit.filterInput.clear': 'Clear the filter';
13
+ readonly 'enonic.uiKit.filterInput.cleared': 'Filter cleared';
14
+ readonly 'enonic.uiKit.filterInput.noMatches': 'No matches';
15
+ readonly 'enonic.uiKit.filterInput.loading': 'Loading…';
16
+ readonly 'enonic.uiKit.filterInput.fieldPrefix': '{0}:';
17
+ readonly 'enonic.uiKit.filterInput.term': '{0}: {1}';
18
+ readonly 'enonic.uiKit.filterInput.remove': 'Remove {0}';
19
+ readonly 'enonic.uiKit.filterInput.added': 'Added {0}';
20
+ readonly 'enonic.uiKit.filterInput.removed': 'Removed {0}';
21
+ readonly 'enonic.uiKit.filterInput.replaced': 'Replaced {0} with {1}';
22
+ } & {
23
+ readonly 'enonic.uiKit.themeSwitcher.light': 'Light theme';
24
+ readonly 'enonic.uiKit.themeSwitcher.dark': 'Dark theme';
25
+ readonly 'enonic.uiKit.themeSwitcher.system': 'System preference';
26
+ readonly 'enonic.uiKit.themeSwitcher.switchToDark': 'Switch to dark theme';
27
+ readonly 'enonic.uiKit.themeSwitcher.switchToSystem': 'Use system theme';
28
+ readonly 'enonic.uiKit.themeSwitcher.switchToLight': 'Switch to light theme';
29
+ } & {
3
30
  readonly 'enonic.uiKit.dialog.cancel': 'Cancel';
4
31
  readonly 'enonic.uiKit.dialog.confirm': 'Confirm';
5
32
  readonly 'enonic.uiKit.dialog.delete': 'Delete';
@@ -1,11 +1,7 @@
1
+ import { filterInputPhrases } from "../filter/filter-input.phrases.js";
2
+ import { themeSwitcherPhrases } from "../theme/theme-switcher.phrases.js";
1
3
  import { mergePhrases } from "@enonic/ui-utils";
2
4
  //#region src/i18n/phrases.ts
3
- /**
4
- * Every text the package renders, in English, keyed `enonic.uiKit.<area>.<name>`. An application
5
- * translates them through the `Translate` it hands `@enonic/ui`'s `I18nProvider`; a key it has no
6
- * text for renders this English. `comparePhrases` from `@enonic/ui-utils` tells an application
7
- * which of these its bundle lacks.
8
- */
9
5
  const dialogPhrases = {
10
6
  "enonic.uiKit.dialog.cancel": "Cancel",
11
7
  "enonic.uiKit.dialog.confirm": "Confirm",
@@ -16,7 +12,18 @@ const gatePhrases = {
16
12
  "enonic.uiKit.gate.hint": "Type {0} to confirm",
17
13
  "enonic.uiKit.gate.mismatch": "{1} does not match {0}"
18
14
  };
19
- const uiKitPhrases = mergePhrases([dialogPhrases, gatePhrases]);
15
+ /**
16
+ * Every text the package renders, in English, keyed `enonic.uiKit.<area>.<name>`. An application
17
+ * translates them through the `Translate` it hands `@enonic/ui`'s `I18nProvider`; a key it has no
18
+ * text for renders this English. `comparePhrases` from `@enonic/ui-utils` tells an application
19
+ * which of these its bundle lacks.
20
+ */
21
+ const uiKitPhrases = mergePhrases([
22
+ dialogPhrases,
23
+ gatePhrases,
24
+ filterInputPhrases,
25
+ themeSwitcherPhrases
26
+ ]);
20
27
  //#endregion
21
28
  export { dialogPhrases, gatePhrases, uiKitPhrases };
22
29
 
@@ -1 +1 @@
1
- {"version":3,"file":"phrases.js","names":[],"sources":["../../src/i18n/phrases.ts"],"sourcesContent":["import { mergePhrases } from '@enonic/ui-utils';\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 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\nexport const uiKitPhrases = mergePhrases([dialogPhrases, gatePhrases]);\n\nexport type UiKitPhraseKey = keyof typeof uiKitPhrases;\n"],"mappings":";;;;;;;;AAQA,MAAa,gBAAgB;CAC3B,8BAA8B;CAC9B,+BAA+B;CAC/B,8BAA8B;CAC9B,4BAA4B;AAC9B;AAEA,MAAa,cAAc;CACzB,0BAA0B;CAC1B,8BAA8B;AAChC;AAEA,MAAa,eAAe,aAAa,CAAC,eAAe,WAAW,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 { 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"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ActionDialogContextValue, ActionDialogOpenChangeDetails, useActionDialog } from "./dialog/action-dialog-context.js";
1
+ import { ActionDialogCloseReason, ActionDialogContextValue, ActionDialogOpenChangeDetails, useActionDialog } from "./dialog/action-dialog-context.js";
2
2
  import { ActionDialog, ActionDialogActionProps, ActionDialogBodyProps, ActionDialogContentProps, ActionDialogFooterProps, ActionDialogRootProps, DialogIntent, DialogSize, FooterQuestion } from "./dialog/action-dialog.js";
3
3
  import { DeleteTarget, deleteExpectation } from "./dialog/delete-expectation.js";
4
4
  import { DeleteConfirm, DeleteConfirmProps } from "./dialog/delete-confirm.js";
@@ -13,4 +13,7 @@ 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
- export { ActionDialog, type ActionDialogActionProps, type ActionDialogBodyProps, 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, type UiKitPhraseKey, type UiKitTranslate, deleteExpectation, fieldTerm, fillPhrase, filterInputPhrases, isFieldTerm, isTextTerm, matchesExpected, sameTerm, termKey, textOf, textTerm, toggledTerm, uiKitPhrases, useActionDialog, useCloseGuard, useUiKitPhrases, valuesOf, withTerm, withoutTerm };
16
+ import { ThemeSwitcher, ThemeSwitcherProps } from "./theme/theme-switcher.js";
17
+ import { ThemeSwitcherPhraseKey, themeSwitcherPhrases } from "./theme/theme-switcher.phrases.js";
18
+ 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 };
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { useActionDialog } from "./dialog/action-dialog-context.js";
2
+ import { filterInputPhrases } from "./filter/filter-input.phrases.js";
3
+ import { themeSwitcherPhrases } from "./theme/theme-switcher.phrases.js";
2
4
  import { uiKitPhrases } from "./i18n/phrases.js";
3
5
  import { useUiKitPhrases } from "./i18n/use-phrases.js";
4
6
  import { ActionDialog } from "./dialog/action-dialog.js";
@@ -10,6 +12,7 @@ import { DeleteConfirm } from "./dialog/delete-confirm.js";
10
12
  import { DialogPreset } from "./dialog/dialog-preset.js";
11
13
  import { useCloseGuard } from "./dialog/use-close-guard.js";
12
14
  import { EMPTY_FILTER, fieldTerm, isFieldTerm, isTextTerm, sameTerm, termKey, textOf, textTerm, toggledTerm, valuesOf, withTerm, withoutTerm } from "./filter/filter-query.js";
13
- import { filterInputPhrases } from "./filter/filter-input.phrases.js";
14
15
  import { FilterInput } from "./filter/filter-input.js";
15
- export { ActionDialog, DeleteConfirm, DialogPreset, EMPTY_FILTER, FilterInput, Gate, deleteExpectation, fieldTerm, fillPhrase, filterInputPhrases, isFieldTerm, isTextTerm, matchesExpected, sameTerm, termKey, textOf, textTerm, toggledTerm, uiKitPhrases, useActionDialog, useCloseGuard, useUiKitPhrases, valuesOf, withTerm, withoutTerm };
16
+ import { ThemeSwitcher } from "./theme/theme-switcher.js";
17
+ 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 };
@@ -0,0 +1,14 @@
1
+ import { ReactElement } from "react";
2
+ //#region src/theme/theme-switcher.d.ts
3
+ type ThemeSwitcherProps = {
4
+ className?: string;
5
+ 'data-component'?: string;
6
+ };
7
+ declare const THEME_SWITCHER_NAME = "ThemeSwitcher";
8
+ declare function ThemeSwitcher({ className, 'data-component': componentName }: ThemeSwitcherProps): ReactElement;
9
+ declare namespace ThemeSwitcher {
10
+ export { THEME_SWITCHER_NAME as displayName };
11
+ }
12
+ //#endregion
13
+ export { ThemeSwitcher, ThemeSwitcherProps };
14
+ //# sourceMappingURL=theme-switcher.d.ts.map
@@ -0,0 +1,71 @@
1
+ import { themeSwitcherPhrases } from "./theme-switcher.phrases.js";
2
+ import { useSyncExternalStore } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Button, Tooltip, cn, usePhrases } from "@enonic/ui";
5
+ import { Moon, Sun, SunMoon } from "lucide-react";
6
+ import { readTheme, subscribeTheme, writeTheme } from "@enonic/ui-utils";
7
+ //#region src/theme/theme-switcher.tsx
8
+ const THEME_SWITCHER_NAME = "ThemeSwitcher";
9
+ const NEXT_THEME = {
10
+ light: "dark",
11
+ dark: "system",
12
+ system: "light"
13
+ };
14
+ const TITLE_KEYS = {
15
+ light: "enonic.uiKit.themeSwitcher.light",
16
+ dark: "enonic.uiKit.themeSwitcher.dark",
17
+ system: "enonic.uiKit.themeSwitcher.system"
18
+ };
19
+ const ACTION_KEYS = {
20
+ light: "enonic.uiKit.themeSwitcher.switchToDark",
21
+ dark: "enonic.uiKit.themeSwitcher.switchToSystem",
22
+ system: "enonic.uiKit.themeSwitcher.switchToLight"
23
+ };
24
+ const ICON_CLASS_NAME = "absolute transition-all duration-300";
25
+ const SHOWN_CLASS_NAME = "scale-100 rotate-0 opacity-100";
26
+ const HIDDEN_CLASS_NAME = "scale-0 -rotate-90 opacity-0";
27
+ const iconClassName = (shown) => cn(ICON_CLASS_NAME, shown ? SHOWN_CLASS_NAME : HIDDEN_CLASS_NAME);
28
+ /**
29
+ * Cycles the theme the admin applications share — light, dark, system — and shows the one picked.
30
+ * It reads and writes the stored theme itself, so every switcher and every page follows a click,
31
+ * here or in another tab; what an application paints comes from `useResolvedTheme`.
32
+ */
33
+ const ThemeSwitcher = ({ className, "data-component": componentName = THEME_SWITCHER_NAME }) => {
34
+ const theme = useSyncExternalStore(subscribeTheme, readTheme);
35
+ const t = usePhrases(themeSwitcherPhrases);
36
+ return /* @__PURE__ */ jsx(Tooltip, {
37
+ value: t(TITLE_KEYS[theme]),
38
+ delay: 300,
39
+ asChild: true,
40
+ children: /* @__PURE__ */ jsxs(Button, {
41
+ "data-component": componentName,
42
+ size: "sm",
43
+ variant: "text",
44
+ "aria-label": t(ACTION_KEYS[theme]),
45
+ onClick: () => writeTheme(NEXT_THEME[theme]),
46
+ className: cn("relative size-9 rounded-sm p-0", className),
47
+ children: [
48
+ /* @__PURE__ */ jsx(Sun, {
49
+ className: iconClassName(theme === "light"),
50
+ size: 16,
51
+ strokeWidth: 1.5
52
+ }),
53
+ /* @__PURE__ */ jsx(Moon, {
54
+ className: iconClassName(theme === "dark"),
55
+ size: 16,
56
+ strokeWidth: 1.5
57
+ }),
58
+ /* @__PURE__ */ jsx(SunMoon, {
59
+ className: iconClassName(theme === "system"),
60
+ size: 18,
61
+ strokeWidth: 1.5
62
+ })
63
+ ]
64
+ })
65
+ });
66
+ };
67
+ ThemeSwitcher.displayName = THEME_SWITCHER_NAME;
68
+ //#endregion
69
+ export { ThemeSwitcher };
70
+
71
+ //# sourceMappingURL=theme-switcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-switcher.js","names":[],"sources":["../../src/theme/theme-switcher.tsx"],"sourcesContent":["import { Button, cn, Tooltip, usePhrases } from '@enonic/ui';\nimport type { Theme } from '@enonic/ui-types';\nimport { readTheme, subscribeTheme, writeTheme } from '@enonic/ui-utils';\nimport { Moon, Sun, SunMoon } from 'lucide-react';\nimport { type ReactElement, useSyncExternalStore } from 'react';\n\nimport { type ThemeSwitcherPhraseKey, themeSwitcherPhrases } from './theme-switcher.phrases';\n\nexport type ThemeSwitcherProps = {\n className?: string;\n 'data-component'?: string;\n};\n\nconst THEME_SWITCHER_NAME = 'ThemeSwitcher';\n\nconst NEXT_THEME: Record<Theme, Theme> = { light: 'dark', dark: 'system', system: 'light' };\n\nconst TITLE_KEYS: Record<Theme, ThemeSwitcherPhraseKey> = {\n light: 'enonic.uiKit.themeSwitcher.light',\n dark: 'enonic.uiKit.themeSwitcher.dark',\n system: 'enonic.uiKit.themeSwitcher.system',\n};\n\nconst ACTION_KEYS: Record<Theme, ThemeSwitcherPhraseKey> = {\n light: 'enonic.uiKit.themeSwitcher.switchToDark',\n dark: 'enonic.uiKit.themeSwitcher.switchToSystem',\n system: 'enonic.uiKit.themeSwitcher.switchToLight',\n};\n\nconst ICON_CLASS_NAME = 'absolute transition-all duration-300';\nconst SHOWN_CLASS_NAME = 'scale-100 rotate-0 opacity-100';\nconst HIDDEN_CLASS_NAME = 'scale-0 -rotate-90 opacity-0';\n\nconst iconClassName = (shown: boolean): string =>\n cn(ICON_CLASS_NAME, shown ? SHOWN_CLASS_NAME : HIDDEN_CLASS_NAME);\n\n/**\n * Cycles the theme the admin applications share — light, dark, system — and shows the one picked.\n * It reads and writes the stored theme itself, so every switcher and every page follows a click,\n * here or in another tab; what an application paints comes from `useResolvedTheme`.\n */\nconst ThemeSwitcher = ({\n className,\n 'data-component': componentName = THEME_SWITCHER_NAME,\n}: ThemeSwitcherProps): ReactElement => {\n const theme = useSyncExternalStore(subscribeTheme, readTheme);\n const t = usePhrases(themeSwitcherPhrases);\n\n return (\n <Tooltip value={t(TITLE_KEYS[theme])} delay={300} asChild>\n <Button\n data-component={componentName}\n size=\"sm\"\n variant=\"text\"\n aria-label={t(ACTION_KEYS[theme])}\n onClick={() => writeTheme(NEXT_THEME[theme])}\n className={cn('relative size-9 rounded-sm p-0', className)}\n >\n <Sun className={iconClassName(theme === 'light')} size={16} strokeWidth={1.5} />\n <Moon className={iconClassName(theme === 'dark')} size={16} strokeWidth={1.5} />\n <SunMoon className={iconClassName(theme === 'system')} size={18} strokeWidth={1.5} />\n </Button>\n </Tooltip>\n );\n};\n\nThemeSwitcher.displayName = THEME_SWITCHER_NAME;\n\nexport { ThemeSwitcher };\n"],"mappings":";;;;;;;AAaA,MAAM,sBAAsB;AAE5B,MAAM,aAAmC;CAAE,OAAO;CAAQ,MAAM;CAAU,QAAQ;AAAQ;AAE1F,MAAM,aAAoD;CACxD,OAAO;CACP,MAAM;CACN,QAAQ;AACV;AAEA,MAAM,cAAqD;CACzD,OAAO;CACP,MAAM;CACN,QAAQ;AACV;AAEA,MAAM,kBAAkB;AACxB,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;AAE1B,MAAM,iBAAiB,UACrB,GAAG,iBAAiB,QAAQ,mBAAmB,iBAAiB;;;;;;AAOlE,MAAM,iBAAiB,EACrB,WACA,kBAAkB,gBAAgB,0BACI;CACtC,MAAM,QAAQ,qBAAqB,gBAAgB,SAAS;CAC5D,MAAM,IAAI,WAAW,oBAAoB;CAEzC,OACE,oBAAC,SAAD;EAAS,OAAO,EAAE,WAAW,MAAM;EAAG,OAAO;EAAK,SAAA;EAChD,UAAA,qBAAC,QAAD;GACE,kBAAgB;GAChB,MAAK;GACL,SAAQ;GACR,cAAY,EAAE,YAAY,MAAM;GAChC,eAAe,WAAW,WAAW,MAAM;GAC3C,WAAW,GAAG,kCAAkC,SAAS;GAN3D,UAAA;IAQE,oBAAC,KAAD;KAAK,WAAW,cAAc,UAAU,OAAO;KAAG,MAAM;KAAI,aAAa;IAAM,CAAA;IAC/E,oBAAC,MAAD;KAAM,WAAW,cAAc,UAAU,MAAM;KAAG,MAAM;KAAI,aAAa;IAAM,CAAA;IAC/E,oBAAC,SAAD;KAAS,WAAW,cAAc,UAAU,QAAQ;KAAG,MAAM;KAAI,aAAa;IAAM,CAAA;GAC9E;;CACD,CAAA;AAEb;AAEA,cAAc,cAAc"}
@@ -0,0 +1,17 @@
1
+ //#region src/theme/theme-switcher.phrases.d.ts
2
+ /**
3
+ * The switcher's own English, keyed `enonic.uiKit.themeSwitcher.<name>`: the tooltip names the
4
+ * theme picked, the accessible name what a click does. See `uiKitPhrases`.
5
+ */
6
+ declare const themeSwitcherPhrases: {
7
+ readonly 'enonic.uiKit.themeSwitcher.light': 'Light theme';
8
+ readonly 'enonic.uiKit.themeSwitcher.dark': 'Dark theme';
9
+ readonly 'enonic.uiKit.themeSwitcher.system': 'System preference';
10
+ readonly 'enonic.uiKit.themeSwitcher.switchToDark': 'Switch to dark theme';
11
+ readonly 'enonic.uiKit.themeSwitcher.switchToSystem': 'Use system theme';
12
+ readonly 'enonic.uiKit.themeSwitcher.switchToLight': 'Switch to light theme';
13
+ };
14
+ type ThemeSwitcherPhraseKey = keyof typeof themeSwitcherPhrases;
15
+ //#endregion
16
+ export { ThemeSwitcherPhraseKey, themeSwitcherPhrases };
17
+ //# sourceMappingURL=theme-switcher.phrases.d.ts.map
@@ -0,0 +1,17 @@
1
+ //#region src/theme/theme-switcher.phrases.ts
2
+ /**
3
+ * The switcher's own English, keyed `enonic.uiKit.themeSwitcher.<name>`: the tooltip names the
4
+ * theme picked, the accessible name what a click does. See `uiKitPhrases`.
5
+ */
6
+ const themeSwitcherPhrases = {
7
+ "enonic.uiKit.themeSwitcher.light": "Light theme",
8
+ "enonic.uiKit.themeSwitcher.dark": "Dark theme",
9
+ "enonic.uiKit.themeSwitcher.system": "System preference",
10
+ "enonic.uiKit.themeSwitcher.switchToDark": "Switch to dark theme",
11
+ "enonic.uiKit.themeSwitcher.switchToSystem": "Use system theme",
12
+ "enonic.uiKit.themeSwitcher.switchToLight": "Switch to light theme"
13
+ };
14
+ //#endregion
15
+ export { themeSwitcherPhrases };
16
+
17
+ //# sourceMappingURL=theme-switcher.phrases.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-switcher.phrases.js","names":[],"sources":["../../src/theme/theme-switcher.phrases.ts"],"sourcesContent":["/**\n * The switcher's own English, keyed `enonic.uiKit.themeSwitcher.<name>`: the tooltip names the\n * theme picked, the accessible name what a click does. See `uiKitPhrases`.\n */\nexport const themeSwitcherPhrases = {\n 'enonic.uiKit.themeSwitcher.light': 'Light theme',\n 'enonic.uiKit.themeSwitcher.dark': 'Dark theme',\n 'enonic.uiKit.themeSwitcher.system': 'System preference',\n 'enonic.uiKit.themeSwitcher.switchToDark': 'Switch to dark theme',\n 'enonic.uiKit.themeSwitcher.switchToSystem': 'Use system theme',\n 'enonic.uiKit.themeSwitcher.switchToLight': 'Switch to light theme',\n} as const;\n\nexport type ThemeSwitcherPhraseKey = keyof typeof themeSwitcherPhrases;\n"],"mappings":";;;;;AAIA,MAAa,uBAAuB;CAClC,oCAAoC;CACpC,mCAAmC;CACnC,qCAAqC;CACrC,2CAA2C;CAC3C,6CAA6C;CAC7C,4CAA4C;AAC9C"}
@@ -0,0 +1,10 @@
1
+ import { ResolvedTheme } from "@enonic/ui-types";
2
+ //#region src/theme/use-resolved-theme.d.ts
3
+ /**
4
+ * The theme to paint, re-rendering when the user switches — on this page or in another tab — or
5
+ * the OS does while the theme is `system`. Read from storage, so there is no store to keep.
6
+ */
7
+ declare function useResolvedTheme(): ResolvedTheme;
8
+ //#endregion
9
+ export { useResolvedTheme };
10
+ //# sourceMappingURL=use-resolved-theme.d.ts.map
@@ -0,0 +1,14 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { readResolvedTheme, subscribeResolvedTheme } from "@enonic/ui-utils";
3
+ //#region src/theme/use-resolved-theme.ts
4
+ /**
5
+ * The theme to paint, re-rendering when the user switches — on this page or in another tab — or
6
+ * the OS does while the theme is `system`. Read from storage, so there is no store to keep.
7
+ */
8
+ function useResolvedTheme() {
9
+ return useSyncExternalStore(subscribeResolvedTheme, readResolvedTheme);
10
+ }
11
+ //#endregion
12
+ export { useResolvedTheme };
13
+
14
+ //# sourceMappingURL=use-resolved-theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-resolved-theme.js","names":[],"sources":["../../src/theme/use-resolved-theme.ts"],"sourcesContent":["import type { ResolvedTheme } from '@enonic/ui-types';\nimport { readResolvedTheme, subscribeResolvedTheme } from '@enonic/ui-utils';\nimport { useSyncExternalStore } from 'react';\n\n/**\n * The theme to paint, re-rendering when the user switches — on this page or in another tab — or\n * the OS does while the theme is `system`. Read from storage, so there is no store to keep.\n */\nexport function useResolvedTheme(): ResolvedTheme {\n return useSyncExternalStore(subscribeResolvedTheme, readResolvedTheme);\n}\n"],"mappings":";;;;;;;AAQA,SAAgB,mBAAkC;CAChD,OAAO,qBAAqB,wBAAwB,iBAAiB;AACvE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enonic/ui-kit",
3
- "version": "0.5.1",
3
+ "version": "0.6.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,7 +41,8 @@
41
41
  "provenance": false
42
42
  },
43
43
  "dependencies": {
44
- "@enonic/ui-utils": "^0.5.1",
44
+ "@enonic/ui-types": "^0.6.0",
45
+ "@enonic/ui-utils": "^0.6.0",
45
46
  "lucide-react": "^1.48.0"
46
47
  },
47
48
  "devDependencies": {