@enonic/ui-kit 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -24
- package/dist/dialog/action-dialog-context.d.ts +7 -3
- package/dist/dialog/action-dialog-context.js.map +1 -1
- package/dist/dialog/action-dialog.d.ts +4 -1
- package/dist/dialog/action-dialog.js +69 -27
- package/dist/dialog/action-dialog.js.map +1 -1
- package/dist/dialog/dialog-preset.js +1 -1
- package/dist/dialog/dialog-preset.js.map +1 -1
- package/dist/dialog/use-close-guard.d.ts +3 -3
- package/dist/dialog/use-close-guard.js +3 -3
- package/dist/dialog/use-close-guard.js.map +1 -1
- package/dist/filter/filter-fields.d.ts +1 -1
- package/dist/filter/filter-fields.js.map +1 -1
- package/dist/filter/filter-input.d.ts +2 -2
- package/dist/filter/filter-input.js +14 -17
- package/dist/filter/filter-input.js.map +1 -1
- package/dist/filter/filter-input.phrases.d.ts +1 -6
- package/dist/filter/filter-input.phrases.js +1 -6
- package/dist/filter/filter-input.phrases.js.map +1 -1
- package/dist/i18n/phrases.d.ts +29 -0
- package/dist/i18n/phrases.js +16 -7
- package/dist/i18n/phrases.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/notifications/notification-list.d.ts +18 -0
- package/dist/notifications/notification-list.js +92 -0
- package/dist/notifications/notification-list.js.map +1 -0
- package/dist/notifications/notification-list.phrases.d.ts +9 -0
- package/dist/notifications/notification-list.phrases.js +7 -0
- package/dist/notifications/notification-list.phrases.js.map +1 -0
- package/dist/notifications/use-focus-hand-off.js +43 -0
- package/dist/notifications/use-focus-hand-off.js.map +1 -0
- package/dist/theme/theme-switcher.d.ts +14 -0
- package/dist/theme/theme-switcher.js +71 -0
- package/dist/theme/theme-switcher.js.map +1 -0
- package/dist/theme/theme-switcher.phrases.d.ts +17 -0
- package/dist/theme/theme-switcher.phrases.js +17 -0
- package/dist/theme/theme-switcher.phrases.js.map +1 -0
- package/dist/theme/use-resolved-theme.d.ts +10 -0
- package/dist/theme/use-resolved-theme.js +14 -0
- package/dist/theme/use-resolved-theme.js.map +1 -0
- package/package.json +11 -10
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,
|
|
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
|
+
the theme switcher and the toast stack; layouts, toolbars and the rest of the browse screen follow.
|
|
7
7
|
|
|
8
8
|
A component belongs here when it holds state, coordinates several base components, or encodes a
|
|
9
9
|
screen pattern more than one application repeats. Props in, callbacks out: nothing here reaches
|
|
@@ -54,26 +54,31 @@ tells an application which keys its bundle lacks.
|
|
|
54
54
|
|
|
55
55
|
## What is here
|
|
56
56
|
|
|
57
|
-
| Export | What it does
|
|
58
|
-
| ---------------------------------------------- |
|
|
59
|
-
| `ActionDialog` | `Dialog` from `@enonic/ui` plus an outcome: a `Content`
|
|
60
|
-
| `ActionDialog.Footer` | Cancel and Confirm when given `onConfirm`, or the controls it is given as children — a wizard's step indicator; `intent`, `error`, `confirmRef`; `question` asks in place of the controls
|
|
61
|
-
| `ActionDialog.Action` | a further button of the footer, enabled by the content like Confirm; `intent`, `closeOnClick`
|
|
62
|
-
| `useCloseGuard({ dirty, open, onOpenChange })` | the dirty-close question: the mask, the X, `Escape` and Cancel ask before a close that would lose something; `asking` feeds the footer's `question`, `keep` returns the focus
|
|
63
|
-
| `Gate` | `Root`, `Hint` naming what to type back, `Input` that enables the confirm button on a match and locks
|
|
64
|
-
| `DialogPreset.Confirm` | the plain question: title, question, two answers of equal weight
|
|
65
|
-
| `DeleteConfirm` | the delete view: question, targets, the gate asking for the one name or the count, a red button — the first view, or the view after a list, with `closeOnCancel` off to go back to it
|
|
66
|
-
| `deleteExpectation(targets)` | the one name, or the count
|
|
67
|
-
| `matchesExpected(typed, expected)` | the gate's comparison, spaces around the entry forgiven
|
|
68
|
-
| `fillPhrase(phrase, values)` | a phrase with its `{n}` placeholders replaced by nodes
|
|
69
|
-
| `uiKitPhrases`, `useUiKitPhrases()` | the catalogue, and the hook that resolves it through the application's `Translate`
|
|
70
|
-
| `useActionDialog()` | the content's state, for anything inside it that enables the action
|
|
71
|
-
| `FilterInput` | one input for a list's filter: values of predefined `fields` combined with free text, every term a `Tag`; `value`/`onChange` or `defaultValue`; `disabled`, `readOnly` (the tags alone, no input), `error`; the ref is the text input's
|
|
72
|
-
| `FilterField`, `FilterValue` | what the dropdown offers: a field with an id, a label, an icon, its values, `multiple` and a `notice`; a value with a `count`, a `group` and `disabled`
|
|
73
|
-
| `FilterQuery`, `FilterTerm` | what the list is narrowed by: `FieldTerm` and `TextTerm`, discriminated by `kind`, ids never labels, in the order they were added; `fieldTerm`, `textTerm`, `termKey`
|
|
74
|
-
| `textOf(query)`, `valuesOf(query, field)` | the two readings a list takes of a query: the free text as one string, and every value picked for a field — empty narrows nothing
|
|
75
|
-
| `withTerm`, `withoutTerm`, `toggledTerm` | the pure operations on a query the input is built from, with `sameTerm`, `isTextTerm`, `isFieldTerm` and `EMPTY_FILTER`
|
|
76
|
-
| `filterInputPhrases` | the input's English,
|
|
57
|
+
| Export | What it does |
|
|
58
|
+
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `ActionDialog` | `Dialog` from `@enonic/ui` plus an outcome: a `Content` whose own controls take no focus from a field (so validate on confirm, not on blur alone), a `Body` inert while the footer asks, a `Footer` that takes the action or asks |
|
|
60
|
+
| `ActionDialog.Footer` | Cancel and Confirm when given `onConfirm`, or the controls it is given as children — a wizard's step indicator; `intent`, `error`, `confirmRef`; `question` asks in place of the controls |
|
|
61
|
+
| `ActionDialog.Action` | a further button of the footer, enabled by the content like Confirm; `intent`, `closeOnClick` |
|
|
62
|
+
| `useCloseGuard({ dirty, open, onOpenChange })` | the dirty-close question: the mask, the X, `Escape` and Cancel ask before a close that would lose something; `asking` feeds the footer's `question`, `keep` returns the focus |
|
|
63
|
+
| `Gate` | `Root`, `Hint` naming what to type back, `Input` that enables the confirm button on a match and locks |
|
|
64
|
+
| `DialogPreset.Confirm` | the plain question: title, question, two answers of equal weight |
|
|
65
|
+
| `DeleteConfirm` | the delete view: question, targets, the gate asking for the one name or the count, a red button — the first view, or the view after a list, with `closeOnCancel` off to go back to it |
|
|
66
|
+
| `deleteExpectation(targets)` | the one name, or the count |
|
|
67
|
+
| `matchesExpected(typed, expected)` | the gate's comparison, spaces around the entry forgiven |
|
|
68
|
+
| `fillPhrase(phrase, values)` | a phrase with its `{n}` placeholders replaced by nodes |
|
|
69
|
+
| `uiKitPhrases`, `useUiKitPhrases()` | the catalogue, and the hook that resolves it through the application's `Translate` |
|
|
70
|
+
| `useActionDialog()` | the content's state, for anything inside it that enables the action |
|
|
71
|
+
| `FilterInput` | one input for a list's filter: values of predefined `fields` combined with free text, every term a `Tag`; `value`/`onChange` or `defaultValue`; `disabled`, `readOnly` (the tags alone, no input), `error`; the ref is the text input's |
|
|
72
|
+
| `FilterField`, `FilterValue` | what the dropdown offers: a field with an id, a label, an icon, its values, `multiple` (on unless `false`) and a `notice`; a value with a `count`, a `group` and `disabled` |
|
|
73
|
+
| `FilterQuery`, `FilterTerm` | what the list is narrowed by: `FieldTerm` and `TextTerm`, discriminated by `kind`, ids never labels, in the order they were added; `fieldTerm`, `textTerm`, `termKey` |
|
|
74
|
+
| `textOf(query)`, `valuesOf(query, field)` | the two readings a list takes of a query: the free text as one string, and every value picked for a field — empty narrows nothing |
|
|
75
|
+
| `withTerm`, `withoutTerm`, `toggledTerm` | the pure operations on a query the input is built from, with `sameTerm`, `isTextTerm`, `isFieldTerm` and `EMPTY_FILTER` |
|
|
76
|
+
| `filterInputPhrases` | the input's English, part of `uiKitPhrases` |
|
|
77
|
+
| `ThemeSwitcher` | the button that cycles the shared theme — light, dark, system — reading and writing it itself; `className` |
|
|
78
|
+
| `useResolvedTheme()` | the theme to paint, `light` or `dark`, re-rendering when the user switches here or in another tab, or the OS does on `system` |
|
|
79
|
+
| `themeSwitcherPhrases` | the switcher's English, part of `uiKitPhrases` |
|
|
80
|
+
| `NotificationList` | the toast stack over a store from `createNotifications`: one `Toast` per entry with its actions and a close, an action taken dismissing it, two `aria-live` regions announcing them, hover and focus holding a lifetime; `className` places it |
|
|
81
|
+
| `notificationListPhrases` | the stack's English, part of `uiKitPhrases` |
|
|
77
82
|
|
|
78
83
|
## The filter
|
|
79
84
|
|
|
@@ -86,8 +91,8 @@ with `loading` says its values are still on their way. The
|
|
|
86
91
|
dropdown opens on a click, a keystroke or an arrow, not on focus. The fields come first; picking
|
|
87
92
|
one, or typing its label and a colon — or pasting `Field: value` whole — opens its values ordered by
|
|
88
93
|
hits, the empty ones last and disabled, with a `No matches` row when the typed text finds none.
|
|
89
|
-
Enter picks the highlighted value,
|
|
90
|
-
again, or the tag's cross, takes the term back. Typed text with no field entered becomes a tag on
|
|
94
|
+
Enter picks the highlighted value, closes the list and returns the cursor to the input; a value
|
|
95
|
+
picked again, or the tag's cross, takes the term back. Typed text with no field entered becomes a tag on
|
|
91
96
|
Enter. Backspace in the empty input steps out of a field, or takes the last tag back; ArrowLeft at
|
|
92
97
|
the start of the input walks onto the tags, where arrows move between them and Backspace or Delete
|
|
93
98
|
removes one. The right-hand cross clears everything, and leaving the filter drops a field no value
|
|
@@ -105,4 +110,46 @@ const text = textOf(query); // 'alice ward'
|
|
|
105
110
|
<FilterInput fields={fields} value={query} onChange={setQuery} placeholder="Search users" />;
|
|
106
111
|
```
|
|
107
112
|
|
|
113
|
+
## The theme
|
|
114
|
+
|
|
115
|
+
The theme is the one state here that is not the caller's: XP's admin applications share it through
|
|
116
|
+
one stored entry, the contract `@enonic/ui-utils` keeps. So `ThemeSwitcher` takes no value and no
|
|
117
|
+
callback — it reads the stored theme, writes the next one on a click, and follows a switch made
|
|
118
|
+
by another switcher on the page or in another tab. An application only paints:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
const theme = useResolvedTheme(); // 'light' | 'dark'
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
125
|
+
}, [theme]);
|
|
126
|
+
|
|
127
|
+
<ThemeSwitcher className="ml-auto" />;
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Outside a render — a host's `theme`, a script with no framework — `readResolvedTheme` and
|
|
131
|
+
`subscribeResolvedTheme` from `@enonic/ui-utils` answer the same.
|
|
132
|
+
|
|
133
|
+
## The notifications
|
|
134
|
+
|
|
135
|
+
`NotificationList` renders a store the application created with `createNotifications` from
|
|
136
|
+
`@enonic/ui-utils` — the same split as the theme, store below and component above — and nothing
|
|
137
|
+
else: the kit holds no instance. The application exports its one store, renders the list once
|
|
138
|
+
where its shell puts the corner, and hands the same store to every `Host` it creates for a mount.
|
|
139
|
+
An action taken dismisses the toast, and so does the close; a toast raised with `autoHide: false`
|
|
140
|
+
stays until one or the other; a hovered or focused toast holds its lifetime; a toast closed from
|
|
141
|
+
the keyboard hands its focus to the toast next to it, or back to where the stack was entered from;
|
|
142
|
+
the stack announces an info or a success politely and a warning or an error assertively.
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
export const notifications = createNotifications();
|
|
146
|
+
|
|
147
|
+
<NotificationList
|
|
148
|
+
notifications={notifications}
|
|
149
|
+
className="fixed right-6 bottom-6 z-60 max-w-115"
|
|
150
|
+
/>;
|
|
151
|
+
|
|
152
|
+
notifications.notify('success', 'Deleted', { actions: [{ label: 'Undo', onClick: undo }] });
|
|
153
|
+
```
|
|
154
|
+
|
|
108
155
|
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
|
-
/**
|
|
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:
|
|
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
|
|
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"}
|
|
@@ -91,6 +91,9 @@ declare const ActionDialog: {
|
|
|
91
91
|
Close: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<import("@enonic/ui").DialogCloseProps> & {
|
|
92
92
|
ref?: import('preact').Ref<HTMLButtonElement> | undefined;
|
|
93
93
|
}>;
|
|
94
|
+
Shortcut: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<import("@enonic/ui").DialogShortcutProps> & {
|
|
95
|
+
ref?: import('preact').Ref<HTMLButtonElement> | undefined;
|
|
96
|
+
}>;
|
|
94
97
|
Title: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<import("@enonic/ui").DialogTitleProps> & {
|
|
95
98
|
ref?: import('preact').Ref<HTMLHeadingElement> | undefined;
|
|
96
99
|
}>;
|
|
@@ -113,7 +116,7 @@ declare const ActionDialog: {
|
|
|
113
116
|
displayName: string;
|
|
114
117
|
};
|
|
115
118
|
StepIndicator: {
|
|
116
|
-
({ previousLabel, nextLabel, lastStepLabel, onLastStep, dots, disabled, pending, renderDot }: import("@enonic/ui").DialogStepIndicatorProps): ReactElement;
|
|
119
|
+
({ previousLabel, nextLabel, lastStepLabel, onLastStep, dots, disabled, pending, keyboardShortcuts, renderDot }: import("@enonic/ui").DialogStepIndicatorProps): ReactElement;
|
|
117
120
|
displayName: string;
|
|
118
121
|
};
|
|
119
122
|
Root: typeof ActionDialogRoot;
|
|
@@ -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
|
|
16
|
-
const value = useMemo(() => ({
|
|
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
|
|
23
|
-
|
|
24
|
-
onOpenChange?.(open, !open &&
|
|
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
|
|
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.
|
|
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,30 +203,33 @@ 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
|
})]
|
|
170
210
|
}), /* @__PURE__ */ jsxs("div", {
|
|
171
211
|
className: cn("flex flex-1 items-center justify-end gap-2.5", asking && "hidden"),
|
|
172
|
-
children: [
|
|
173
|
-
error
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
children
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
212
|
+
children: [error !== void 0 && /* @__PURE__ */ jsx("p", {
|
|
213
|
+
className: "text-error min-w-0 flex-1 text-sm",
|
|
214
|
+
role: "alert",
|
|
215
|
+
children: error
|
|
216
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
217
|
+
className: "flex shrink-0 items-center justify-end gap-2.5 only:flex-1",
|
|
218
|
+
children: [
|
|
219
|
+
children,
|
|
220
|
+
onConfirm !== void 0 && (closeOnCancel ? /* @__PURE__ */ jsx(Dialog.Close, {
|
|
221
|
+
asChild: true,
|
|
222
|
+
children: cancel
|
|
223
|
+
}) : cancel),
|
|
224
|
+
onConfirm !== void 0 && /* @__PURE__ */ jsx(ActionDialogAction, {
|
|
225
|
+
ref: confirmRef,
|
|
226
|
+
label: confirmLabel ?? t("enonic.uiKit.dialog.confirm"),
|
|
227
|
+
intent,
|
|
228
|
+
closeOnClick: closeOnConfirm,
|
|
229
|
+
onClick: onConfirm
|
|
230
|
+
})
|
|
231
|
+
]
|
|
232
|
+
})]
|
|
191
233
|
})]
|
|
192
234
|
});
|
|
193
235
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action-dialog.js","names":[],"sources":["../../src/dialog/action-dialog.tsx"],"sourcesContent":["import {\n Button,\n type ButtonVariant,\n cn,\n Dialog,\n type DialogRootProps,\n usePrefixedId,\n} from '@enonic/ui';\nimport { TriangleAlert } from 'lucide-react';\nimport {\n type ComponentPropsWithoutRef,\n forwardRef,\n type 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 min-w-0 flex-1 text-sm\" role=\"alert\">\n {error}\n </p>\n )}\n <div className=\"flex shrink-0 items-center justify-end gap-2.5 only:flex-1\">\n {children}\n {onConfirm !== undefined &&\n (closeOnCancel ? <Dialog.Close asChild>{cancel}</Dialog.Close> : cancel)}\n {onConfirm !== undefined && (\n <ActionDialogAction\n ref={confirmRef}\n label={confirmLabel ?? t('enonic.uiKit.dialog.confirm')}\n intent={intent}\n closeOnClick={closeOnConfirm}\n onClick={onConfirm}\n />\n )}\n </div>\n </div>\n </Dialog.Footer>\n );\n },\n);\nActionDialogFooter.displayName = FOOTER_NAME;\n\n//\n// * Compound\n//\n\n/**\n * `Dialog` from `@enonic/ui` plus an outcome: a `Content` that holds whether the action may be\n * taken, a `Body` that goes inert while the footer asks a question, a `Footer` that takes the\n * action or asks the question, and `Action` for a further button the content enables. Stacks\n * nothing: a question goes to the footer, content with room of its own is another `Content` under\n * the same `Root`.\n */\nexport const ActionDialog = {\n ...Dialog,\n Root: ActionDialogRoot,\n Content: ActionDialogContent,\n Body: ActionDialogBody,\n Footer: ActionDialogFooter,\n Action: ActionDialogAction,\n};\n"],"mappings":"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"}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"}
|