@hideyukimori/nene2-ui 0.15.0 โ†’ 0.16.1

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.
@@ -1,24 +1,75 @@
1
1
  import { type ReactNode } from 'react';
2
- export interface ModalProps {
2
+ type ModalSize = 'sm' | 'md' | 'lg';
3
+ interface ModalBase {
3
4
  open: boolean;
4
- /** Called for every dismissal the browser owns: Esc, the close control, the backdrop. */
5
+ /** Called for every dismissal routed through the browser's `close` event: Esc, and `close()`. */
5
6
  onClose: () => void;
6
- /** Localized title. Also names the dialog for assistive tech. */
7
+ /** Localized title. Names the dialog for assistive tech, and is what the header shows. */
7
8
  title: string;
9
+ /**
10
+ * Width ceiling. Omitted, the dialog takes the browser's own sizing โ€” which is what every
11
+ * caller got before this prop existed, so leaving it out changes nothing.
12
+ */
13
+ size?: ModalSize;
14
+ /**
15
+ * On a narrow viewport, sit against the bottom edge with the top corners rounded.
16
+ * Off by default: a dialog that changes shape at a breakpoint is a decision, not a default.
17
+ */
18
+ sheetOnMobile?: boolean;
19
+ /**
20
+ * Scroll the body instead of letting tall content spill out of the dialog. Off by default.
21
+ *
22
+ * ๐Ÿ”ด The kit adds no height cap of its own. The UA stylesheet already caps a `showModal()`
23
+ * dialog to the viewport โ€” inventing a second ceiling here would mean a literal like `80vh`
24
+ * living in the theme, which the kit forbids (every slot default must be a scale reference,
25
+ * and the spacing scale is rem-based and cannot express a share of the viewport). What is
26
+ * actually missing without this prop is the scroll: at the UA cap, tall content is clipped.
27
+ * โš ๏ธ The cap itself is the browser's and is not asserted here โ€” jsdom does not implement
28
+ * `showModal`, so it can only be verified in a real browser (see #392, live lane).
29
+ */
30
+ scrollable?: boolean;
8
31
  children: ReactNode;
9
32
  }
33
+ interface PlainModal extends ModalBase {
34
+ header?: false;
35
+ closeLabel?: never;
36
+ }
37
+ interface HeaderModal extends ModalBase {
38
+ /** Draw a header: the title, and a control that closes the dialog. */
39
+ header: true;
40
+ /**
41
+ * Localized name for the close control.
42
+ *
43
+ * ๐Ÿ”ด Required with `header`, not optional-with-a-default. A default would be an English
44
+ * string shipped into every product's UI โ€” the one thing a fleet kit must not do (I18N-2).
45
+ * The union makes the compiler ask for it exactly when it is going to be rendered.
46
+ */
47
+ closeLabel: string;
48
+ }
49
+ export type ModalProps = PlainModal | HeaderModal;
10
50
  /**
11
51
  * A modal dialog built on the native `<dialog>` element.
12
52
  *
13
53
  * ๐Ÿ”ด Why native. Focus trapping, Esc-to-dismiss, the backdrop, and rendering above every
14
54
  * stacking context are all things the browser already does correctly. Eight ships wrote
15
55
  * their own modal, which means the fleet currently maintains eight focus traps โ€” the single
16
- * hardest piece of interaction code to get right, reimplemented per product.
56
+ * hardest piece of interaction code to get right, reimplemented per product. nene-vault is
57
+ * not one of those eight: it shipped `aria-modal="true"` on a plain element, which announces
58
+ * "everything outside is inert" while Tab walks straight out of it (measured in production
59
+ * 2026-08-25, #392). A dialog that lies to assistive tech is worse than one that admits it
60
+ * is not modal.
17
61
  *
18
62
  * ๐Ÿ”ด `showModal()` is called imperatively rather than through the `open` attribute, because
19
63
  * only `showModal()` puts the dialog in the top layer and traps focus; setting `open`
20
64
  * renders it inline and non-modal. jsdom 25.0.1 does not implement `showModal` at all
21
65
  * (measured 2026-08-23), and neither do browsers older than the feature, so the fallback
22
66
  * sets `open` โ€” degraded but visible, rather than a dialog that never appears.
67
+ *
68
+ * ๐Ÿ”ด Every prop added in 0.16.0 (`header`, `size`, `sheetOnMobile`, `scrollable`) defaults to
69
+ * what the component already rendered, so a caller that passes none of them sees no change.
70
+ * That is only true while the defaults are a copy of the previous rendering โ€” the lesson
71
+ * `--text-x-slot-button-sm-size` taught in #380, where "the default is harmless" held right
72
+ * up until a consumer overrode one side of a pair.
23
73
  */
24
- export declare function Modal({ open, onClose, title, children }: ModalProps): import("react").JSX.Element;
74
+ export declare function Modal(props: ModalProps): import("react").JSX.Element;
75
+ export {};
@@ -1,22 +1,41 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useRef } from 'react';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef } from 'react';
3
3
  import { cx } from '../lib/cx.js';
4
+ const SIZE_CLASS = {
5
+ sm: 'max-w-x-slot-modal-sm',
6
+ md: 'max-w-x-slot-modal-md',
7
+ lg: 'max-w-x-slot-modal-lg',
8
+ };
9
+ /** Bottom sheet below `sm`, ordinary dialog above it. */
10
+ const SHEET_CLASS = 'max-sm:mt-auto max-sm:mb-0 max-sm:w-full max-sm:max-w-none max-sm:rounded-b-none';
4
11
  /**
5
12
  * A modal dialog built on the native `<dialog>` element.
6
13
  *
7
14
  * ๐Ÿ”ด Why native. Focus trapping, Esc-to-dismiss, the backdrop, and rendering above every
8
15
  * stacking context are all things the browser already does correctly. Eight ships wrote
9
16
  * their own modal, which means the fleet currently maintains eight focus traps โ€” the single
10
- * hardest piece of interaction code to get right, reimplemented per product.
17
+ * hardest piece of interaction code to get right, reimplemented per product. nene-vault is
18
+ * not one of those eight: it shipped `aria-modal="true"` on a plain element, which announces
19
+ * "everything outside is inert" while Tab walks straight out of it (measured in production
20
+ * 2026-08-25, #392). A dialog that lies to assistive tech is worse than one that admits it
21
+ * is not modal.
11
22
  *
12
23
  * ๐Ÿ”ด `showModal()` is called imperatively rather than through the `open` attribute, because
13
24
  * only `showModal()` puts the dialog in the top layer and traps focus; setting `open`
14
25
  * renders it inline and non-modal. jsdom 25.0.1 does not implement `showModal` at all
15
26
  * (measured 2026-08-23), and neither do browsers older than the feature, so the fallback
16
27
  * sets `open` โ€” degraded but visible, rather than a dialog that never appears.
28
+ *
29
+ * ๐Ÿ”ด Every prop added in 0.16.0 (`header`, `size`, `sheetOnMobile`, `scrollable`) defaults to
30
+ * what the component already rendered, so a caller that passes none of them sees no change.
31
+ * That is only true while the defaults are a copy of the previous rendering โ€” the lesson
32
+ * `--text-x-slot-button-sm-size` taught in #380, where "the default is harmless" held right
33
+ * up until a consumer overrode one side of a pair.
17
34
  */
18
- export function Modal({ open, onClose, title, children }) {
35
+ export function Modal(props) {
36
+ const { open, onClose, title, size, sheetOnMobile, scrollable, children } = props;
19
37
  const ref = useRef(null);
38
+ const titleId = useId();
20
39
  useEffect(() => {
21
40
  const el = ref.current;
22
41
  if (el === null)
@@ -39,9 +58,20 @@ export function Modal({ open, onClose, title, children }) {
39
58
  el.removeAttribute('open');
40
59
  }
41
60
  }, [open]);
42
- return (_jsx("dialog", { ref: ref, "aria-label": title,
61
+ // The header names the dialog through the heading it already draws; without one there is
62
+ // nothing on screen to point at, so the title has to be carried as a label.
63
+ const naming = props.header ? { 'aria-labelledby': titleId } : { 'aria-label': title };
64
+ return (_jsxs("dialog", { ref: ref, ...naming,
43
65
  // The browser fires `close` for Esc as well as for close(); routing both through the
44
66
  // caller keeps `open` from drifting out of sync with what is on screen.
45
- onClose: onClose, onCancel: onClose, className: cx('bg-x-slot-modal-bg text-x-slot-modal-fg border border-x-slot-modal-border rounded-x-slot-modal', 'p-x-slot-modal-pad font-sans backdrop:bg-x-slot-modal-scrim/50'), children: children }));
67
+ onClose: onClose, onCancel: onClose, className: cx(
68
+ // ๐Ÿ”ด `m-auto` is what centres a `showModal()` dialog. The UA stylesheet already says
69
+ // `dialog { margin: auto }`, but Tailwind's preflight (`* { margin: 0 }`, author origin)
70
+ // erases it, so on every Tailwind ship the dialog sat at (0,0) โ€” measured in nene-vault's
71
+ // production at 1280px, where the old hand-written modal had been at (380,119) (#417).
72
+ // The sheet classes below (`max-sm:mt-auto max-sm:mb-0`) always assumed this margin was
73
+ // there; saying it explicitly is the kit owning an assumption it had been borrowing.
74
+ // jsdom does not implement `showModal`, so the position itself is a live-lane check.
75
+ 'm-auto bg-x-slot-modal-bg text-x-slot-modal-fg border border-x-slot-modal-border rounded-x-slot-modal', 'p-x-slot-modal-pad font-sans backdrop:bg-x-slot-modal-scrim/50', size !== undefined && SIZE_CLASS[size], sheetOnMobile === true && SHEET_CLASS, scrollable === true && 'flex flex-col'), children: [props.header === true && (_jsxs("header", { className: "mb-x-slot-modal-header-gap flex items-start justify-between gap-x-slot-modal-header-gap", children: [_jsx("h2", { id: titleId, className: "font-x-slot-modal-title text-x-slot-modal-title-size", children: title }), _jsx("button", { type: "button", "aria-label": props.closeLabel, onClick: onClose, className: "rounded-x-slot-control text-x-slot-modal-close-fg leading-none hover:brightness-x-slot-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-x-slot-focus-ring", children: _jsx("span", { "aria-hidden": "true", children: "\u00D7" }) })] })), scrollable === true ? _jsx("div", { className: "min-h-0 overflow-y-auto", children: children }) : children] }));
46
76
  }
47
77
  //# sourceMappingURL=Modal.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"Modal.js","sourceRoot":"","sources":["../../src/overlay/Modal.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAkB,MAAM,OAAO,CAAC;AAC1D,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAC;AAWlC;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,KAAK,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAc;IAClE,MAAM,GAAG,GAAG,MAAM,CAAoB,IAAI,CAAC,CAAC;IAE5C,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC;QACvB,IAAI,EAAE,KAAK,IAAI;YAAE,OAAO;QAExB,IAAI,IAAI,EAAE,CAAC;YACT,IAAI,OAAO,EAAE,CAAC,SAAS,KAAK,UAAU,EAAE,CAAC;gBACvC,IAAI,CAAC,EAAE,CAAC,IAAI;oBAAE,EAAE,CAAC,SAAS,EAAE,CAAC;YAC/B,CAAC;iBAAM,CAAC;gBACN,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;YAC9B,CAAC;YACD,OAAO;QACT,CAAC;QAED,IAAI,OAAO,EAAE,CAAC,KAAK,KAAK,UAAU,EAAE,CAAC;YACnC,IAAI,EAAE,CAAC,IAAI;gBAAE,EAAE,CAAC,KAAK,EAAE,CAAC;QAC1B,CAAC;aAAM,CAAC;YACN,EAAE,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IAEX,OAAO,CACL,iBACE,GAAG,EAAE,GAAG,gBACI,KAAK;QACjB,qFAAqF;QACrF,wEAAwE;QACxE,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,OAAO,EACjB,SAAS,EAAE,EAAE,CACX,gGAAgG,EAChG,gEAAgE,CACjE,YAEA,QAAQ,GACF,CACV,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"Modal.js","sourceRoot":"","sources":["../../src/overlay/Modal.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAkB,MAAM,OAAO,CAAC;AACjE,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAC;AAuDlC,MAAM,UAAU,GAA8B;IAC5C,EAAE,EAAE,uBAAuB;IAC3B,EAAE,EAAE,uBAAuB;IAC3B,EAAE,EAAE,uBAAuB;CAC5B,CAAC;AAEF,yDAAyD;AACzD,MAAM,WAAW,GACf,kFAAkF,CAAC;AAErF;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,KAAK,CAAC,KAAiB;IACrC,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC;IAClF,MAAM,GAAG,GAAG,MAAM,CAAoB,IAAI,CAAC,CAAC;IAC5C,MAAM,OAAO,GAAG,KAAK,EAAE,CAAC;IAExB,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC;QACvB,IAAI,EAAE,KAAK,IAAI;YAAE,OAAO;QAExB,IAAI,IAAI,EAAE,CAAC;YACT,IAAI,OAAO,EAAE,CAAC,SAAS,KAAK,UAAU,EAAE,CAAC;gBACvC,IAAI,CAAC,EAAE,CAAC,IAAI;oBAAE,EAAE,CAAC,SAAS,EAAE,CAAC;YAC/B,CAAC;iBAAM,CAAC;gBACN,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;YAC9B,CAAC;YACD,OAAO;QACT,CAAC;QAED,IAAI,OAAO,EAAE,CAAC,KAAK,KAAK,UAAU,EAAE,CAAC;YACnC,IAAI,EAAE,CAAC,IAAI;gBAAE,EAAE,CAAC,KAAK,EAAE,CAAC;QAC1B,CAAC;aAAM,CAAC;YACN,EAAE,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IAEX,yFAAyF;IACzF,4EAA4E;IAC5E,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC;IAEvF,OAAO,CACL,kBACE,GAAG,EAAE,GAAG,KACJ,MAAM;QACV,qFAAqF;QACrF,wEAAwE;QACxE,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,OAAO,EACjB,SAAS,EAAE,EAAE;QACX,qFAAqF;QACrF,yFAAyF;QACzF,0FAA0F;QAC1F,uFAAuF;QACvF,wFAAwF;QACxF,qFAAqF;QACrF,qFAAqF;QACrF,uGAAuG,EACvG,gEAAgE,EAChE,IAAI,KAAK,SAAS,IAAI,UAAU,CAAC,IAAI,CAAC,EACtC,aAAa,KAAK,IAAI,IAAI,WAAW,EACrC,UAAU,KAAK,IAAI,IAAI,eAAe,CACvC,aAEA,KAAK,CAAC,MAAM,KAAK,IAAI,IAAI,CACxB,kBAAQ,SAAS,EAAC,yFAAyF,aACzG,aAAI,EAAE,EAAE,OAAO,EAAE,SAAS,EAAC,sDAAsD,YAC9E,KAAK,GACH,EACL,iBACE,IAAI,EAAC,QAAQ,gBACD,KAAK,CAAC,UAAU,EAC5B,OAAO,EAAE,OAAO,EAChB,SAAS,EAAC,6LAA6L,YAIvM,8BAAkB,MAAM,uBAAc,GAC/B,IACF,CACV,EACA,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,cAAK,SAAS,EAAC,yBAAyB,YAAE,QAAQ,GAAO,CAAC,CAAC,CAAC,QAAQ,IACpF,CACV,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hideyukimori/nene2-ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "NeNe fleet shared React UI kit โ€” token-driven primitives on Tailwind v4 @theme. Components carry no design of their own; themes do.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -389,6 +389,27 @@
389
389
  --text-x-slot-button-sm-size: inherit;
390
390
  --text-x-slot-choice-size: inherit;
391
391
 
392
+ /* โ”€โ”€ Modal slots (0.16.0 / #392) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
393
+ * ๐Ÿ”ด Every one of these is reached only through a prop that defaults to off, so adding
394
+ * them changes nothing for a caller that passes none. The dialog itself is unchanged.
395
+ *
396
+ * The three widths choose Tailwind's own container steps rather than inventing lengths.
397
+ * nene-vault measured 432px and 520px in production; the fleet's own dialogs cluster at
398
+ * 560px (15 uses), 640 (6), 680 (4), 600 (4), 420 (4), 520 (3), 460 (3) โ€” measured across
399
+ * each ship's `frontend/src` on 2026-08-25. `md`/`lg`/`xl` cover those clusters, and a product
400
+ * whose value is not on the scale overrides the slot rather than the scale (README ยงโ‘ ). */
401
+ --container-x-slot-modal-sm: var(--container-md);
402
+ --container-x-slot-modal-md: var(--container-lg);
403
+ --container-x-slot-modal-lg: var(--container-xl);
404
+
405
+ --spacing-x-slot-modal-header-gap: var(--spacing-x-xs);
406
+ --font-weight-x-slot-modal-title: var(--font-weight-medium);
407
+ /* `inherit`, so the header title takes the dialog's own size unless a product says
408
+ * otherwise. There is no previous rendering to copy โ€” the header is new โ€” and picking a
409
+ * step here would decide type for every product that turns the header on. */
410
+ --text-x-slot-modal-title-size: inherit;
411
+ --color-x-slot-modal-close-fg: var(--color-text-muted);
412
+
392
413
  /* โ”€โ”€ Shadow slots โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
393
414
  * ๐Ÿ”ด Shadow was the one dimension that never got slots (#386). 0.9.0 moved "colour, weight
394
415
  * and the type scale" through slots and the enumeration in that sentence decided the