@pathscale/ui 3.0.0 → 3.1.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 (52) hide show
  1. package/dist/components/auth-field-group/AuthFieldGroup.css +11 -3
  2. package/dist/components/button/Button.css +4 -4
  3. package/dist/components/card/Card.generated.d.ts +21 -2
  4. package/dist/components/card/index.d.ts +1 -1
  5. package/dist/components/checkbox/Checkbox.generated.d.ts +23 -2
  6. package/dist/components/checkbox/Checkbox.generated.js +14 -6
  7. package/dist/components/connection-settings/ConnectionSettings.css +76 -0
  8. package/dist/components/connection-settings/ConnectionSettings.generated.d.ts +45 -0
  9. package/dist/components/connection-settings/ConnectionSettings.generated.js +300 -0
  10. package/dist/components/connection-settings/ConnectionSettings.recipe.d.ts +54 -0
  11. package/dist/components/connection-settings/ConnectionSettings.recipe.js +111 -0
  12. package/dist/components/connection-settings/index.d.ts +2 -0
  13. package/dist/components/connection-settings/index.js +1 -0
  14. package/dist/components/dialog/Dialog.generated.js +17 -79
  15. package/dist/components/drawer/Drawer.a11y.d.ts +1 -3
  16. package/dist/components/drawer/Drawer.a11y.js +2 -30
  17. package/dist/components/drawer/Drawer.generated.js +17 -40
  18. package/dist/components/grid/Grid.generated.js +3 -3
  19. package/dist/components/input/Input.css +5 -3
  20. package/dist/components/input/Input.generated.js +6 -3
  21. package/dist/components/password-field/PasswordField.generated.d.ts +9 -1
  22. package/dist/components/password-field/PasswordField.generated.js +2 -2
  23. package/dist/components/password-field/PasswordField.interactions.d.ts +2 -2
  24. package/dist/components/password-field/PasswordField.interactions.js +2 -2
  25. package/dist/components/popover/Popover.generated.js +14 -11
  26. package/dist/components/radio/Radio.generated.d.ts +26 -2
  27. package/dist/components/radio/Radio.generated.js +9 -5
  28. package/dist/components/select/Select.generated.js +5 -0
  29. package/dist/components/slider/Slider.generated.d.ts +14 -1
  30. package/dist/components/slider/Slider.generated.js +3 -2
  31. package/dist/components/switch/Switch.css +0 -3
  32. package/dist/components/switch/Switch.generated.d.ts +23 -2
  33. package/dist/components/switch/Switch.generated.js +21 -44
  34. package/dist/components/switch/Switch.recipe.d.ts +61 -38
  35. package/dist/components/switch/Switch.recipe.js +88 -68
  36. package/dist/components/text/Text.generated.js +2 -6
  37. package/dist/components/text/Text.recipe.d.ts +17 -5
  38. package/dist/components/text/Text.recipe.js +7 -13
  39. package/dist/hooks/connection/createConnectionSettings.d.ts +128 -0
  40. package/dist/hooks/connection/createConnectionSettings.js +211 -0
  41. package/dist/hooks/connection/index.d.ts +2 -0
  42. package/dist/hooks/connection/index.js +1 -0
  43. package/dist/index.d.ts +5 -1
  44. package/dist/index.js +2 -0
  45. package/dist/layouts.manifest.json +1 -1
  46. package/dist/lib/focus.d.ts +31 -0
  47. package/dist/lib/focus.js +39 -0
  48. package/dist/lib/overlay.d.ts +94 -0
  49. package/dist/lib/overlay.js +72 -0
  50. package/dist/purge-manifest.json +14427 -15269
  51. package/dist/styles/base/index.css +42 -0
  52. package/package.json +3 -3
@@ -0,0 +1,128 @@
1
+ import type { Accessor } from "solid-js";
2
+ /**
3
+ * Where an application points itself, and how that survives a reload.
4
+ *
5
+ * Every property in this family ships a "connection settings" page: a toggle,
6
+ * one or more backend URLs, an app id, and a save that reconfigures the
7
+ * transport. Six of them had written it separately, in two different shapes and
8
+ * with different bugs, which is what this replaces.
9
+ *
10
+ * The storage model is per-endpoint rather than global. Four sites had a single
11
+ * `useCustomUrl` covering every backend at once; two had already grown a flag
12
+ * per backend, because pointing the API at a local instance while leaving auth
13
+ * on production is the ordinary case. Per-endpoint is the shape that covers
14
+ * both, and {@link ConnectionSettingsStore.setUseCustom} flips them together
15
+ * for a page that only wants one switch.
16
+ */
17
+ export interface ConnectionEndpoint {
18
+ /** Stable key. Names the value in storage and in {@link ConnectionSettingsStore.urls}. */
19
+ name: string;
20
+ /** The address used whenever this endpoint's override is off. */
21
+ fallback: string;
22
+ /**
23
+ * Reject an address before it is saved. Return a message to refuse it, or
24
+ * nothing to accept.
25
+ *
26
+ * Defaults to {@link isAbsoluteUrl}: the value has to parse as an absolute
27
+ * URL. That is deliberately weak, because this hook does not know what a
28
+ * given endpoint speaks. An endpoint that knows should say so -- a WebSocket
29
+ * transport handed `http://…` fails at connect time, long after the page
30
+ * that could have explained it has gone.
31
+ */
32
+ validate?: (url: string) => string | undefined;
33
+ }
34
+ /** What {@link ConnectionSettingsStore.apply} hands to `onApply`. */
35
+ export interface ConnectionSettingsApplied {
36
+ /** Resolved per endpoint, overrides taken into account. Never empty. */
37
+ urls: Readonly<Record<string, string>>;
38
+ appPublicId: string;
39
+ /** Which endpoints the resolved address came from an override for. */
40
+ overrides: Readonly<Record<string, boolean>>;
41
+ }
42
+ /**
43
+ * Whether a bracketed host is an IPv6 literal.
44
+ *
45
+ * `https://[1:2:3]` is not an address, and a character class of hex digits and
46
+ * colons said it was. This is the shape the URL Standard describes: eight
47
+ * 16-bit groups, or fewer with exactly one `::` standing for the run of zeroes
48
+ * it elides, optionally ending in a dotted IPv4 form.
49
+ *
50
+ * Written out rather than folded into one pattern because the `::` rule is a
51
+ * count, not a shape: each half has to parse, and together they have to leave
52
+ * at least one group for the `::` to stand for.
53
+ */
54
+ export declare const isIpv6Literal: (host: string) => boolean;
55
+ export declare const isAbsoluteUrl: (url: string) => string | undefined;
56
+ export interface ConnectionSettingsOptions {
57
+ /** `localStorage` key. Namespace it per application; two apps on one origin would collide. */
58
+ storageKey: string;
59
+ endpoints: readonly ConnectionEndpoint[];
60
+ /** The application's own id, when it has one. Stored alongside the URLs. */
61
+ appPublicId?: string;
62
+ /**
63
+ * Run after a successful save or reset, with everything that was applied.
64
+ *
65
+ * This is where an application reconfigures its transport. It is awaited, so
66
+ * `isApplying` covers the reconnect rather than just the write.
67
+ *
68
+ * It receives the whole applied state, not only the URLs: a site that stores
69
+ * an app id here reconfigures its identity from the same call, and passing
70
+ * the URLs alone left it reaching back into the store it had just handed to
71
+ * this hook.
72
+ */
73
+ onApply?: (applied: ConnectionSettingsApplied) => void | Promise<void>;
74
+ }
75
+ export interface ConnectionSettingsState {
76
+ /** Per endpoint: is the override in use. Absent means no. */
77
+ overrides: Record<string, boolean>;
78
+ /** Per endpoint: the address to use when its override is on. */
79
+ urls: Record<string, string>;
80
+ appPublicId: string;
81
+ }
82
+ export interface ConnectionSettingsStore {
83
+ /** What is stored, override flags included. Read this to populate a form. */
84
+ readonly state: ConnectionSettingsState;
85
+ /**
86
+ * The address for each endpoint after overrides are applied. This is what a
87
+ * transport should read; it never contains an empty string.
88
+ */
89
+ readonly urls: Readonly<Record<string, string>>;
90
+ /** The address each endpoint falls back to, by name. What "not overridden" means. */
91
+ readonly fallbacks: Readonly<Record<string, string>>;
92
+ /** True while `onApply` is in flight. */
93
+ readonly isApplying: boolean;
94
+ /** Nothing has been overridden and the app id is untouched. */
95
+ readonly isAtDefaults: boolean;
96
+ /** True when this endpoint is overridden. */
97
+ isOverridden(name: string): boolean;
98
+ /**
99
+ * Why this address cannot be saved for this endpoint, or `undefined` if it
100
+ * can. Empty is always refused; beyond that the endpoint's own `validate`
101
+ * decides, defaulting to {@link isAbsoluteUrl}.
102
+ *
103
+ * A settings page is the last place able to explain a bad address. Saved
104
+ * unchecked, the value survives a failed reconnect and is still there on the
105
+ * next launch, with nothing on screen saying why nothing connects.
106
+ */
107
+ validate(name: string, url: string): string | undefined;
108
+ /**
109
+ * Set an endpoint's override.
110
+ *
111
+ * Returns the reason it was refused, or `undefined` when it was stored. A
112
+ * value written here becomes an active, persisted override, so it has to
113
+ * pass the same check {@link ConnectionSettingsStore.validate} applies;
114
+ * an invalid one is not stored at all. Keep it as a draft in the UI instead.
115
+ */
116
+ setUrl(name: string, url: string): string | undefined;
117
+ setOverride(name: string, on: boolean): void;
118
+ /** Flip every endpoint together, for a page with one switch. */
119
+ setUseCustom(on: boolean): void;
120
+ setAppPublicId(id: string): void;
121
+ /** Drop every override and forget the stored copy. Does not apply. */
122
+ reset(): void;
123
+ /** Persist, then hand the resolved addresses to `onApply`. */
124
+ apply(): Promise<void>;
125
+ /** For a component that wants to track the state rather than read it once. */
126
+ state$: Accessor<ConnectionSettingsState>;
127
+ }
128
+ export declare const createConnectionSettings: (options: ConnectionSettingsOptions) => ConnectionSettingsStore;
@@ -0,0 +1,211 @@
1
+ import { createEffect, createRoot, createSignal } from "solid-js";
2
+ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\/(\[[^\]\s]*\]|[^\s/?#:]+)(?::(\d{1,5}))?(?:[/?#]\S*)?$/i;
3
+ const IPV6_GROUP = "[0-9a-f]{1,4}";
4
+ const IPV4_TAIL = "(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
5
+ const IPV6_FULL = new RegExp(`^(?:${IPV6_GROUP}:){6}(?:${IPV6_GROUP}:${IPV6_GROUP}|${IPV4_TAIL})$`, "i");
6
+ const IPV6_PIECES = new RegExp(`^(?:${IPV6_GROUP}(?::${IPV6_GROUP})*)?$`, "i");
7
+ const isIpv6Literal = (host)=>{
8
+ if (IPV6_FULL.test(host)) return true;
9
+ const halves = host.split("::");
10
+ if (2 !== halves.length) return false;
11
+ const [head, tail] = halves;
12
+ const embeddedV4 = new RegExp(`(?:^|:)(${IPV4_TAIL})$`, "i").exec(tail);
13
+ const groups = embeddedV4 ? tail.slice(0, tail.length - embeddedV4[1].length).replace(/:$/, "") : tail;
14
+ if (!IPV6_PIECES.test(head)) return false;
15
+ if (!IPV6_PIECES.test(groups)) return false;
16
+ const count = (part)=>"" === part ? 0 : part.split(":").length;
17
+ return count(head) + count(groups) + (embeddedV4 ? 2 : 0) <= 7;
18
+ };
19
+ const isAbsoluteUrl = (url)=>{
20
+ const match = ABSOLUTE_URL.exec(url);
21
+ if (!match) return `${url} is not an absolute address (expected scheme://host)`;
22
+ const [, host, port] = match;
23
+ if (host.startsWith("[") && !isIpv6Literal(host.slice(1, -1))) return `${url} does not contain a valid IPv6 address`;
24
+ if (void 0 !== port && Number(port) > 65535) return `${url} has a port outside 0-65535`;
25
+ };
26
+ const isRecord = (value)=>"object" == typeof value && null !== value && !Array.isArray(value);
27
+ const createConnectionSettings = (options)=>{
28
+ const { storageKey, endpoints, appPublicId = "", onApply } = options;
29
+ const fallbacks = Object.freeze(Object.fromEntries(endpoints.map((e)=>[
30
+ e.name,
31
+ e.fallback
32
+ ])));
33
+ const defaults = ()=>({
34
+ overrides: {},
35
+ urls: Object.fromEntries(endpoints.map((e)=>[
36
+ e.name,
37
+ e.fallback
38
+ ])),
39
+ appPublicId
40
+ });
41
+ const read = ()=>{
42
+ const base = defaults();
43
+ try {
44
+ if ("u" < typeof localStorage) return base;
45
+ const raw = localStorage.getItem(storageKey);
46
+ if (!raw) return base;
47
+ const parsed = JSON.parse(raw);
48
+ if (!isRecord(parsed)) return base;
49
+ const overrides = {};
50
+ const urls = {
51
+ ...base.urls
52
+ };
53
+ const storedOverrides = isRecord(parsed.overrides) ? parsed.overrides : {};
54
+ const storedUrls = isRecord(parsed.urls) ? parsed.urls : {};
55
+ for (const endpoint of endpoints){
56
+ const url = storedUrls[endpoint.name];
57
+ const usable = "string" == typeof url && "" !== url && void 0 === (endpoint.validate ?? isAbsoluteUrl)(url);
58
+ if (usable) urls[endpoint.name] = url;
59
+ if (usable && true === storedOverrides[endpoint.name]) overrides[endpoint.name] = true;
60
+ }
61
+ return {
62
+ overrides,
63
+ urls,
64
+ appPublicId: "string" == typeof parsed.appPublicId ? parsed.appPublicId : base.appPublicId
65
+ };
66
+ } catch {
67
+ return base;
68
+ }
69
+ };
70
+ const [state, setState, isApplying, setIsApplying] = createRoot(()=>{
71
+ const [s, setS] = createSignal(read());
72
+ const [a, setA] = createSignal(false);
73
+ return [
74
+ s,
75
+ setS,
76
+ a,
77
+ setA
78
+ ];
79
+ });
80
+ const atDefaults = (value)=>value.appPublicId === appPublicId && endpoints.every((e)=>true !== value.overrides[e.name] && value.urls[e.name] === e.fallback);
81
+ const write = (value)=>{
82
+ try {
83
+ if ("u" < typeof localStorage) return;
84
+ if (atDefaults(value)) localStorage.removeItem(storageKey);
85
+ else localStorage.setItem(storageKey, JSON.stringify(value));
86
+ } catch {}
87
+ };
88
+ createRoot(()=>{
89
+ createEffect(()=>state(), (value)=>{
90
+ write(value);
91
+ });
92
+ });
93
+ let committed = state();
94
+ let inFlight = 0;
95
+ let queue = Promise.resolve();
96
+ const update = (change)=>{
97
+ committed = change(committed);
98
+ setState(committed);
99
+ };
100
+ const resolveFrom = (current)=>Object.fromEntries(endpoints.map((e)=>{
101
+ const override = true === current.overrides[e.name] ? current.urls[e.name] : void 0;
102
+ return [
103
+ e.name,
104
+ override && "" !== override ? override : e.fallback
105
+ ];
106
+ }));
107
+ let resolvedFor;
108
+ let resolvedValue = Object.freeze({});
109
+ const resolved = ()=>{
110
+ const current = state();
111
+ if (resolvedFor !== current) {
112
+ resolvedFor = current;
113
+ resolvedValue = Object.freeze(resolveFrom(current));
114
+ }
115
+ return resolvedValue;
116
+ };
117
+ return {
118
+ get state () {
119
+ return state();
120
+ },
121
+ get urls () {
122
+ return resolved();
123
+ },
124
+ validate (name, url) {
125
+ const endpoint = endpoints.find((e)=>e.name === name);
126
+ if (!endpoint) return `${name} is not a configured endpoint`;
127
+ if ("" === url) return "an address is required";
128
+ return (endpoint.validate ?? isAbsoluteUrl)(url);
129
+ },
130
+ get fallbacks () {
131
+ return fallbacks;
132
+ },
133
+ get isApplying () {
134
+ return isApplying();
135
+ },
136
+ get isAtDefaults () {
137
+ state();
138
+ return atDefaults(committed);
139
+ },
140
+ state$: state,
141
+ isOverridden (name) {
142
+ return true === state().overrides[name];
143
+ },
144
+ setUrl (name, url) {
145
+ if ("" !== url) {
146
+ const endpoint = endpoints.find((e)=>e.name === name);
147
+ const problem = endpoint ? (endpoint.validate ?? isAbsoluteUrl)(url) : `${name} is not a configured endpoint`;
148
+ if (problem) return problem;
149
+ }
150
+ update((c)=>({
151
+ ...c,
152
+ urls: {
153
+ ...c.urls,
154
+ [name]: url
155
+ }
156
+ }));
157
+ },
158
+ setOverride (name, on) {
159
+ update((c)=>({
160
+ ...c,
161
+ overrides: {
162
+ ...c.overrides,
163
+ [name]: on
164
+ }
165
+ }));
166
+ },
167
+ setUseCustom (on) {
168
+ update((c)=>({
169
+ ...c,
170
+ overrides: Object.fromEntries(endpoints.map((e)=>[
171
+ e.name,
172
+ on
173
+ ]))
174
+ }));
175
+ },
176
+ setAppPublicId (id) {
177
+ update((c)=>({
178
+ ...c,
179
+ appPublicId: id
180
+ }));
181
+ },
182
+ reset () {
183
+ committed = defaults();
184
+ setState(committed);
185
+ },
186
+ async apply () {
187
+ inFlight += 1;
188
+ setIsApplying(true);
189
+ const current = committed;
190
+ const reconnect = ()=>{
191
+ write(current);
192
+ return onApply?.({
193
+ urls: resolveFrom(current),
194
+ appPublicId: current.appPublicId,
195
+ overrides: {
196
+ ...current.overrides
197
+ }
198
+ }) ?? void 0;
199
+ };
200
+ const run = 1 === inFlight ? (async ()=>reconnect())() : queue.then(reconnect).then(()=>void 0);
201
+ queue = run.catch(()=>void 0);
202
+ try {
203
+ await run;
204
+ } finally{
205
+ inFlight -= 1;
206
+ if (0 === inFlight) setIsApplying(false);
207
+ }
208
+ }
209
+ };
210
+ };
211
+ export { createConnectionSettings, isAbsoluteUrl, isIpv6Literal };
@@ -0,0 +1,2 @@
1
+ export type { ConnectionEndpoint, ConnectionSettingsApplied, ConnectionSettingsOptions, ConnectionSettingsState, ConnectionSettingsStore, } from "./createConnectionSettings";
2
+ export { createConnectionSettings, isAbsoluteUrl } from "./createConnectionSettings";
@@ -0,0 +1 @@
1
+ export { createConnectionSettings, isAbsoluteUrl } from "./createConnectionSettings.js";
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export type { ButtonProps } from "./components/button";
20
20
  export { default as Button } from "./components/button";
21
21
  export { PanelToggle, type PanelToggleProps, type PanelToggleSide, } from "./components/panel-toggle";
22
22
  export { type CalendarDayHoverHandler, type CalendarDaySelectHandler, type CalendarProps, type CalendarSelectionMode, type CalendarWeekdayFormat, default as Calendar, } from "./components/calendar";
23
- export type { CardElevation, CardMaterial, CardProps, CardSectionProps, } from "./components/card";
23
+ export type { CardElevation, CardMaterial, CardState, CardProps, CardSectionProps, } from "./components/card";
24
24
  export { Card as CardRoot, CardBody, CardFooter, CardHeader, default as Card, } from "./components/card";
25
25
  export { default as ChatBubble } from "./components/chatbubble";
26
26
  export { default as Checkbox } from "./components/checkbox";
@@ -123,6 +123,10 @@ export type { AnyFormApi, CreateFormOptions, FormApi, UseFieldResult, } from "./
123
123
  export { createForm, FormContext, getFirstFieldError, useField, useFormContext, } from "./hooks/form";
124
124
  export type { CreateMutationOptions, CreateQueryOptions, MutationResult, QueryResult, } from "./hooks/data";
125
125
  export { createMutation, createQuery, invalidateQueries, } from "./hooks/data";
126
+ export type { ConnectionEndpoint, ConnectionSettingsApplied, ConnectionSettingsOptions, ConnectionSettingsState, ConnectionSettingsStore, } from "./hooks/connection";
127
+ export { createConnectionSettings, isAbsoluteUrl } from "./hooks/connection";
128
+ export type { ConnectionSettingsEndpointLabel, ConnectionSettingsLabels, ConnectionSettingsProps, } from "./components/connection-settings";
129
+ export { ConnectionSettings } from "./components/connection-settings";
126
130
  export { useDesktop } from "./hooks/layout";
127
131
  export type { UseAnchoredOverlayPositionOptions } from "./hooks/table";
128
132
  export { useAnchoredOverlayPosition } from "./hooks/table";
package/dist/index.js CHANGED
@@ -73,6 +73,8 @@ export { TooltipArrow, TooltipContent, TooltipTrigger, default as Tooltip } from
73
73
  export { FLAVORS, SIZES, SPACES, STATES, VARIANTS, isInvalid, resolveState } from "./components/vocabulary.js";
74
74
  export { FormContext, createForm, getFirstFieldError, useField, useFormContext } from "./hooks/form/index.js";
75
75
  export { createMutation, createQuery, invalidateQueries } from "./hooks/data/index.js";
76
+ export { createConnectionSettings, isAbsoluteUrl } from "./hooks/connection/index.js";
77
+ export { ConnectionSettings } from "./components/connection-settings/index.js";
76
78
  export { useDesktop } from "./hooks/layout/index.js";
77
79
  export { useAnchoredOverlayPosition } from "./hooks/table/index.js";
78
80
  export { evaluatePasswordRules, matchPasswordConfirmation } from "./passwordRules.js";
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "format": "solid-layouts-library-v2",
3
3
  "package": "@pathscale/ui",
4
- "version": "3.0.0",
4
+ "version": "3.1.0",
5
5
  "components": {
6
6
  "Accordion": {
7
7
  "kind": "embedded"
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Keyboard focus, for the overlays that have to contain it.
3
+ *
4
+ * Here rather than in a component's own module because two components had
5
+ * their own copy of the same twenty lines -- Drawer's in `Drawer.a11y.ts` and
6
+ * Dialog's written out inline -- and the overlay manager now needs it too. A
7
+ * `lib` importing from a `components` directory to reach one of those copies
8
+ * would be the layering inverted to avoid moving a helper.
9
+ */
10
+ /** Everything inside `container` a person can Tab to, in document order. */
11
+ export declare const getFocusable: (container: HTMLElement) => HTMLElement[];
12
+ /** Move focus to the first thing inside `container` that will take it. */
13
+ export declare const focusFirst: (container: HTMLElement) => void;
14
+ /**
15
+ * Keep Tab inside a scope, wrapping at both ends.
16
+ *
17
+ * The scope is a list, not one element, because a modal's content is not
18
+ * always one subtree: a Popover opened from inside a Dialog portals its
19
+ * content elsewhere in the document, and it is still part of what the person
20
+ * is looking at. Trapping in the Dialog's element alone would make the popover
21
+ * unreachable by keyboard; treating the popover as the scope would let Tab
22
+ * leave the modal behind it. Both belong.
23
+ *
24
+ * Order matters: the containers are given innermost-last, and the focusables
25
+ * are concatenated in that order, so tabbing off the end of the newest overlay
26
+ * wraps to the start of the modal that owns the scope.
27
+ *
28
+ * A scope with nothing focusable in it takes focus itself, so Tab cannot
29
+ * escape a modal that is still loading its content.
30
+ */
31
+ export declare const trapFocus: (event: KeyboardEvent, scope: HTMLElement | HTMLElement[]) => void;
@@ -0,0 +1,39 @@
1
+ const FOCUSABLE_SELECTOR = "a[href],area[href],button:not([disabled]),input:not([disabled]):not([type='hidden']),select:not([disabled]),textarea:not([disabled]),[contenteditable='true'],[tabindex]:not([tabindex='-1'])";
2
+ const getFocusable = (container)=>Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el)=>!el.hidden && el.tabIndex >= 0 && "true" !== el.getAttribute("aria-hidden"));
3
+ const focusFirst = (container)=>{
4
+ const autofocus = container.querySelector("[autofocus]");
5
+ if (autofocus) return void autofocus.focus();
6
+ const nodes = getFocusable(container);
7
+ if (nodes.length > 0) return void nodes[0].focus();
8
+ container.focus();
9
+ };
10
+ const trapFocus = (event, scope)=>{
11
+ const containers = Array.isArray(scope) ? scope : [
12
+ scope
13
+ ];
14
+ const container = containers[0];
15
+ if (!container) return;
16
+ const nodes = containers.flatMap((element)=>getFocusable(element));
17
+ if (0 === nodes.length) {
18
+ event.preventDefault();
19
+ container.focus();
20
+ return;
21
+ }
22
+ const first = nodes[0];
23
+ const last = nodes[nodes.length - 1];
24
+ const active = document.activeElement;
25
+ if (!event.shiftKey && active === last) {
26
+ event.preventDefault();
27
+ first.focus();
28
+ return;
29
+ }
30
+ if (event.shiftKey && (active === first || containers.some((element)=>active === element))) {
31
+ event.preventDefault();
32
+ last.focus();
33
+ }
34
+ if (active && !containers.some((element)=>element.contains(active))) {
35
+ event.preventDefault();
36
+ (event.shiftKey ? last : first).focus();
37
+ }
38
+ };
39
+ export { focusFirst, getFocusable, trapFocus };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * One owner for the things overlapping overlays have to agree about.
3
+ *
4
+ * Dialog, Drawer and Popover each had their own copy of this, which is fine
5
+ * until two of them are open at once, and then it is not:
6
+ *
7
+ * - **Escape closed every layer.** Each component bound its own `keydown` on
8
+ * `document`, and every listener that saw the key acted on it. Opening a
9
+ * Popover from inside a Dialog and pressing Escape dismissed both, because
10
+ * nothing established which overlay owned the event.
11
+ *
12
+ * - **The body could be left unscrollable for good.** Dialog and Drawer had
13
+ * *separate* module-scope lock counters, each saving `document.body.style
14
+ * .overflow` the first time it locked. Dialog opens and saves `""`. Drawer
15
+ * opens and saves `"hidden"` -- Dialog's value, not the page's. Dialog closes
16
+ * and restores `""`. Drawer closes and restores `"hidden"`. The page is now
17
+ * stuck, and nothing on screen says why.
18
+ *
19
+ * Components supply policy -- whether they are dismissable, what closing means
20
+ * -- and this owns the mechanism.
21
+ */
22
+ type DismissReason = "escape";
23
+ interface OverlayEntry {
24
+ dismiss: (reason: DismissReason) => void;
25
+ /** Asked at dismiss time, not at registration: `closeOnEscape` can change. */
26
+ dismissable: () => boolean;
27
+ /**
28
+ * This overlay's own content, asked at key time because it is portalled and
29
+ * mounts after registration.
30
+ *
31
+ * Declared by modal and non-modal overlays alike. A Popover is not modal and
32
+ * still has to be part of the focus scope of whatever it was opened from, or
33
+ * Tab cannot reach it.
34
+ */
35
+ element?: () => HTMLElement | undefined;
36
+ /**
37
+ * Whether this overlay contains focus while it is open.
38
+ *
39
+ * True for Dialog and Drawer, false for Popover. It decides who *owns* the
40
+ * scope; `element` decides what is *in* it.
41
+ *
42
+ * Here rather than on each component for the same reason `dismiss` is.
43
+ * Dialog and Drawer each bound their own document-level Tab listener and
44
+ * each gated it on "am I visible", so with both open both traps ran and the
45
+ * one behind could pull focus out of the one in front. Which overlay owns
46
+ * the keyboard is a question about all of them.
47
+ */
48
+ modal?: boolean;
49
+ }
50
+ /**
51
+ * The elements Tab may move within, given what is open.
52
+ *
53
+ * The innermost *modal* owns the scope, and everything opened above it is
54
+ * inside that scope. Empty when nothing open contains focus, which is Tab
55
+ * belonging to the page.
56
+ *
57
+ * Asking only the top entry was wrong in the one arrangement this manager
58
+ * exists for: a Popover opened from inside a Dialog is the top entry and is
59
+ * not modal, so Tab did nothing at all and focus walked out to the page behind
60
+ * a dialog that was still open -- worse than before the manager, where the
61
+ * Dialog's own listener at least still ran. Trapping in the modal's element
62
+ * alone is the opposite failure: the popover's portalled content sits outside
63
+ * that element and Tab could never reach it.
64
+ *
65
+ * Exported for tests. This repository has no DOM in its unit tests -- the
66
+ * harness is the DOM-level instrument -- and the bugs here are arrangements
67
+ * rather than components, which is exactly what a one-component-per-page sweep
68
+ * cannot set up. Keeping the decision pure is what makes it assertable at all.
69
+ */
70
+ export declare const focusScope: (entries: readonly OverlayEntry[]) => HTMLElement[];
71
+ /**
72
+ * Claim the keyboard while an overlay is open.
73
+ *
74
+ * Call this **when the overlay becomes visible**, not when the component
75
+ * mounts, and call the returned function when it closes. The stack is the
76
+ * order overlays opened in, and it can only be that if registration is.
77
+ *
78
+ * The most recently registered overlay owns Escape, and — when it declares
79
+ * `trapFocusIn` — Tab.
80
+ */
81
+ export declare const registerOverlay: (entry: OverlayEntry) => (() => void);
82
+ /** How many overlays currently own dismissal. Exposed for tests. */
83
+ export declare const overlayDepth: () => number;
84
+ /**
85
+ * Hold the body still while an overlay is open.
86
+ *
87
+ * One counter for the whole library. The saved values are captured on the
88
+ * first lock and restored on the last release, so overlapping overlays cannot
89
+ * save each other's `hidden` and restore it afterwards.
90
+ */
91
+ export declare const lockBodyScroll: () => (() => void);
92
+ /** Outstanding scroll locks. Exposed for tests. */
93
+ export declare const bodyScrollLockDepth: () => number;
94
+ export {};
@@ -0,0 +1,72 @@
1
+ import { trapFocus } from "./focus.js";
2
+ const stack = [];
3
+ let keydownBound = false;
4
+ const overlay_top = ()=>stack[stack.length - 1];
5
+ const focusScope = (entries)=>{
6
+ for(let i = entries.length - 1; i >= 0; i -= 1)if (entries[i]?.modal) return entries.slice(i).map((entry)=>entry.element?.()).filter((element)=>Boolean(element));
7
+ return [];
8
+ };
9
+ const handleKeyDown = (event)=>{
10
+ if (event.defaultPrevented) return;
11
+ const entry = overlay_top();
12
+ if (!entry) return;
13
+ if ("Tab" === event.key) {
14
+ const scope = focusScope(stack);
15
+ if (scope.length > 0) trapFocus(event, scope);
16
+ return;
17
+ }
18
+ if ("Escape" !== event.key) return;
19
+ if (!entry.dismissable()) return;
20
+ event.preventDefault();
21
+ entry.dismiss("escape");
22
+ };
23
+ const bindKeyDown = ()=>{
24
+ if (keydownBound || "u" < typeof document) return;
25
+ document.addEventListener("keydown", handleKeyDown);
26
+ keydownBound = true;
27
+ };
28
+ const unbindKeyDown = ()=>{
29
+ if (!keydownBound || stack.length > 0 || "u" < typeof document) return;
30
+ document.removeEventListener("keydown", handleKeyDown);
31
+ keydownBound = false;
32
+ };
33
+ const registerOverlay = (entry)=>{
34
+ stack.push(entry);
35
+ bindKeyDown();
36
+ let released = false;
37
+ return ()=>{
38
+ if (released) return;
39
+ released = true;
40
+ const index = stack.lastIndexOf(entry);
41
+ if (-1 !== index) stack.splice(index, 1);
42
+ unbindKeyDown();
43
+ };
44
+ };
45
+ const overlayDepth = ()=>stack.length;
46
+ let lockCount = 0;
47
+ let restoreOverflow = "";
48
+ let restorePaddingRight = "";
49
+ const lockBodyScroll = ()=>{
50
+ if ("u" < typeof document) return ()=>{};
51
+ if (0 === lockCount) {
52
+ restoreOverflow = document.body.style.overflow;
53
+ restorePaddingRight = document.body.style.paddingRight;
54
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
55
+ if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
56
+ document.body.style.overflow = "hidden";
57
+ }
58
+ lockCount += 1;
59
+ let released = false;
60
+ return ()=>{
61
+ if (released) return;
62
+ released = true;
63
+ if (lockCount <= 0) return;
64
+ lockCount -= 1;
65
+ if (0 === lockCount) {
66
+ document.body.style.overflow = restoreOverflow;
67
+ document.body.style.paddingRight = restorePaddingRight;
68
+ }
69
+ };
70
+ };
71
+ const bodyScrollLockDepth = ()=>lockCount;
72
+ export { bodyScrollLockDepth, focusScope, lockBodyScroll, overlayDepth, registerOverlay };