@flood-md/react 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,11 +6,22 @@ HTML semantics and refs, no third-party component library or Tailwind dependency
6
6
  Named imports support tree shaking in optimizing ES-module bundlers. Import the
7
7
  shared stylesheet explicitly; styles are not split by component.
8
8
 
9
+ Sheet is a bottom-aligned Drawer for short tasks, navigation and touch-friendly
10
+ choices. It fits short content, caps long content to the viewport, and keeps
11
+ Drawer dismissal, focus restoration, body scrolling and the optional footer.
12
+
9
13
  SegmentedControl options can display a `count` alongside the label, for example
10
14
  `{value: "pending", label: "Pending", count: 2}`. Use this for a small, mutually
11
15
  exclusive choice that updates one collection. The count is read with the visible
12
16
  label and does not change the submitted value.
13
17
 
18
+ Version 0.2.12 adds opt-in DataTable column visibility. Set
19
+ `showColumnVisibility` for the Columns menu, and supply `hiddenColumnIds` and
20
+ `onHiddenColumnIdsChange` when the host saves preferences. The active sort and
21
+ record-opening columns stay visible. See [DataTable](../../docs/components/data-table.md).
22
+ Pass the Columns control from `toolbar={({ columns }) => …}` into
23
+ `CollectionToolbar.actions` to keep it alongside search and filters.
24
+
14
25
  The 0.2.11 candidate adds optional DataTable row `primaryAction` for opening an
15
26
  in-place detail panel, plus `toolbar` and `inset="roomy"` for a shared table edge.
16
27
  The title is a native button and the rest of the row also responds to pointer
@@ -46,7 +57,7 @@ the interactive size. It defaults to size; shrinking a supporting glyph must
46
57
  not shrink its target or bypass IconSlot with a custom SVG width. Existing glyph
47
58
  roles at a 16px root are 2xs=14px, xs/sm=16px and md/lg=20px.
48
59
 
49
- This package documents the **0.2.11** source API. Keep Flood package versions aligned and follow the guide matching
60
+ This package documents the **0.2.12** source API. Keep Flood package versions aligned and follow the guide matching
50
61
  your installed declarations; source changes do not modify an existing npm release.
51
62
 
52
63
  NavigationItem (0.2.11 candidate) is the shared small row for destinations,
@@ -431,6 +442,10 @@ edge-to-edge content when the host already provides safe padding. The default
431
442
  padding can wrap text and change content height. Native
432
443
  fallback scrollbars retain their platform behavior.
433
444
 
445
+ AppShell content mode uses the same ScrollArea. For a workspace whose layout
446
+ already provides clearance, `scrollContentInset="none"` keeps the content
447
+ width stable when rails appear. The default protects edge-to-edge content.
448
+
434
449
  ```tsx
435
450
  <ScrollArea aria-label="Activity" style={{ maxHeight: "12rem" }}>
436
451
  <Stack gap="4">{/* Long activity content */}</Stack>
@@ -604,8 +619,9 @@ Available in 0.2.3. Import Flood CSS and use FloodRoot for dark foundations.
604
619
 
605
620
  Disclosure corner geometry: Accordion headers have square internal seams. Only
606
621
  the first header has upper shell corners, and only a closed last header has lower
607
- shell corners. An open Collapsible header has square lower corners where its
608
- panel begins. Hover backgrounds follow that same shape without clipping focus.
622
+ shell corners. A standalone Collapsible has an inset, rounded trigger and a
623
+ separate gap before the revealed content. The trigger label and panel share
624
+ an inline edge.
609
625
 
610
626
 
611
627
  ## Tooltip, Popover and Dialog
@@ -804,7 +820,12 @@ assertive uses role=alert. Choose urgency deliberately for dynamically inserted
804
820
  content. Announcements depend on actual assistive technology; initial static
805
821
  markup is not guaranteed to be read aloud. No focus movement, auto-dismiss,
806
822
  timeout or built-in close/action button is supplied. Compose named host actions
807
- inside children when needed. Alert is separate from Dialog and future Toast.
823
+ inside children when needed. A titleless Alert centers its glyph alongside a
824
+ single Inline row. Subtle and ghost actions inherit the feedback tint on
825
+ interactive states. Use a floating ToastRegion for brief operation results.
826
+ ToastRegion defaults to `placement="bottom-end"`. Choose `bottom-start`,
827
+ `top-end` or `top-start` for a different logical corner, or `inline` to
828
+ embed it in a layout. New floating cards enter from their placement edge.
808
829
 
809
830
  See the [WAI Meter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/meter/),
810
831
  [Alert pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/) and
@@ -953,6 +974,9 @@ visually while retaining their full text/title. A hidden counter frees its slot.
953
974
  The host owns accepted changes, captured IDs, confirmation and focus after the
954
975
  bar disappears. Positioning is host-owned: use a stable bounded width for a
955
976
  floating rail, plus viewport safe-area spacing and measured scroll clearance.
977
+ For table selection, place the rail at the viewport's bottom center. Set
978
+ `showSelection={false}` on DataTable to keep its count available to assistive
979
+ technology while BulkActionBar displays the visible count.
956
980
 
957
981
  OverflowActions also accepts leading/trailing slots and maxVisibleActions. Slot
958
982
  width is reserved before measuring commands; use action owners in those slots
@@ -963,6 +987,9 @@ under one named action panel. After hydration it uses one Tab entry, directional
963
987
  arrows, Home/End and optional looping. Disabled/hidden actions are skipped. Arrows
964
988
  move focus, not activation. Horizontal order follows writing direction. In vertical
965
989
  toolbars open Menu with Enter/Space; horizontal menus retain Up/Down opening.
990
+ A vertical pill centers its actions on a single axis. Use same-size icon controls
991
+ with accessible names for a compact rail. Use a rounded toolbar for a vertical
992
+ group of labeled actions.
966
993
  Keep text inputs and independent selection widgets outside this action-only API.
967
994
  Native child handlers and refs remain attached; Toolbar owns their tabIndex while
968
995
  mounted and restores original attributes on cleanup. Without JavaScript, native
@@ -1109,6 +1136,7 @@ relative path, without reading their contents. Single mode replaces its selectio
1109
1136
 
1110
1137
  AlertDialog (0.2.5) is a concise confirmation, composed from Flood's
1111
1138
  native modal layer and Button. Supply title, description, trigger and onConfirm.
1139
+ Line breaks in the description separate distinct thoughts while allowing each line to wrap.
1112
1140
  Cancel receives initial focus; Escape cancels; outside clicks do not dismiss.
1113
1141
  Tab containment, background inertness, scroll lock and return focus reuse Dialog.
1114
1142
  Translate confirmLabel/cancelLabel. onConfirm runs before requesting close, while
@@ -1145,7 +1173,8 @@ The CSS build assembles the original component styles into the shared stylesheet
1145
1173
 
1146
1174
 
1147
1175
  Section headingLevel controls native h2–h6 semantics. Its independent headingSize
1148
- uses the existing Heading roles (section/page/large/display), defaulting to section.
1176
+ uses the Heading roles (group/section/page/large/display), defaulting to section.
1177
+ Choose group for a compact section inside a Drawer or another constrained panel.
1149
1178
  Keep document hierarchy meaningful when changing visual emphasis.
1150
1179
 
1151
1180
  ## Editable and TagInput · 0.2.7
@@ -1192,8 +1221,11 @@ when a query changes; ready rows must match pageSize/totalRows. Selection defaul
1192
1221
  to the supplied page in server mode and the full collection in client mode.
1193
1222
  Server selectionScope="dataset" requires controlled selectedIds/onSelectionChange;
1194
1223
  the host validates IDs outside the page. Checkboxes only select the current page.
1195
- This API does not fetch, cancel requests, select an entire remote query, virtualize
1196
- or manage column visibility/resizing/pinning.
1224
+ The host owns request execution and cancellation, remote query selection and
1225
+ virtualization. DataTable supports optional column visibility, width preferences,
1226
+ logical edge pinning and expandable record details. Use Sparkline for a compact
1227
+ trend cell. See [DataTable](../../docs/components/data-table.md) and
1228
+ [Sparkline](../../docs/components/sparkline.md).
1197
1229
 
1198
1230
  DatePicker and DateRangePicker pass viewLabels to their calendars. Calendar panels
1199
1231
  use minimal Popover chrome, with Escape/selection/trigger dismissal; there is no
package/dist/app-shell.js CHANGED
@@ -5,7 +5,7 @@ const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLa
5
5
  export const ApplicationTopBar = /* @__PURE__ */ forwardRef(function ApplicationTopBar({ start, end, bordered, className, ...props }, ref) {
6
6
  return _jsxs("header", { ...props, ref: ref, className: ["flood-app-top-bar", className].filter(Boolean).join(" "), "data-bordered": bordered || undefined, children: [start != null && _jsx(Inline, { align: "center", gap: "2", className: "flood-app-top-bar__start", children: start }), end != null && _jsx(Inline, { align: "center", gap: "2", className: "flood-app-top-bar__end", children: end })] });
7
7
  });
8
- export const ApplicationShell = /* @__PURE__ */ forwardRef(function ApplicationShell({ navigation, navigationSide, navigationDrawer, topBar, footer, scroll, mainId, mainAs: Main = "main", mainLabel, mainRef, scrollLabel, scrollOrientation = "both", skipLabel, children, className, ...props }, ref) {
8
+ export const ApplicationShell = /* @__PURE__ */ forwardRef(function ApplicationShell({ navigation, navigationSide, navigationDrawer, topBar, footer, scroll, mainId, mainAs: Main = "main", mainLabel, mainRef, scrollLabel, scrollOrientation = "both", scrollContentInset = "scrollbar", skipLabel, children, className, ...props }, ref) {
9
9
  const generatedId = useId();
10
10
  const contentId = mainId ?? `flood-app-${generatedId}`;
11
11
  const root = useRef(null);
@@ -61,6 +61,6 @@ export const ApplicationShell = /* @__PURE__ */ forwardRef(function ApplicationS
61
61
  ? topBar(navigationTrigger)
62
62
  : _jsxs(_Fragment, { children: [navigationTrigger && _jsx(AppTopBar, { start: navigationTrigger }), topBar] });
63
63
  return _jsxs("div", { ...props, ref: root, className: ["flood-app-shell", className].filter(Boolean).join(" "), "data-scroll": scroll, children: [_jsx(SkipLink, { href: `#${contentId}`, children: skipLabel }), navigationSide === "start" && navigationSlot, _jsxs("div", { className: "flood-app-shell__workspace", children: [chrome, _jsx(Main, { ref: mainRef, id: contentId, "aria-label": mainLabel, tabIndex: -1, className: "flood-app-shell__main", children: scroll === "content"
64
- ? _jsx(ScrollArea, { orientation: scrollOrientation, "aria-label": scrollLabel, className: "flood-app-shell__scroll", children: children })
64
+ ? _jsx(ScrollArea, { orientation: scrollOrientation, contentInset: scrollContentInset, "aria-label": scrollLabel, className: "flood-app-shell__scroll", children: children })
65
65
  : children }), footer] }), navigationSide === "end" && navigationSlot] });
66
66
  });
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useId, useImperativeHandle, useRef } from 'react';
3
3
  import { Button, Field, Fieldset, IconSlot, Inline, Popover, SearchInput, Tag, Text } from './index.js';
4
4
  /** Query chrome only: the host supplies filter drafts, accepted query and saved views. */
5
- export const CollectionQueryBar = /* @__PURE__ */ forwardRef(function CollectionQueryBar({ label, searchValue, onSearchValueChange, searchLabel = 'Search', searchPlaceholder = 'Search…', clearSearchLabel = 'Clear search', appliedFilters = [], onRemoveFilter, appliedFiltersLabel = 'Applied filters', summaryPrefix, quickFilters, filterContent, filtersLabel = 'Filters', filterTitle = filtersLabel, filtersOpen, onFiltersOpenChange, filterPanelRef, filterPanelClassName, onClear, canClear = Boolean(searchValue || appliedFilters.length), clearLabel = 'Clear filters', controlSize = 'sm', disabled = false, hideLabel = true, className, ...props }, forwardedRef) {
5
+ export const CollectionQueryBar = /* @__PURE__ */ forwardRef(function CollectionQueryBar({ label, searchValue, onSearchValueChange, searchLabel = 'Search', searchPlaceholder = 'Search…', clearSearchLabel = 'Clear search', appliedFilters = [], onRemoveFilter, appliedFiltersLabel = 'Applied filters', summaryPrefix, quickFilters, actions, filterContent, filtersLabel = 'Filters', filterTitle = filtersLabel, filtersOpen, onFiltersOpenChange, filterPanelRef, filterPanelClassName, onClear, canClear = Boolean(searchValue || appliedFilters.length), clearLabel = 'Clear filters', controlSize = 'sm', disabled = false, hideLabel = true, className, ...props }, forwardedRef) {
6
6
  const root = useRef(null);
7
7
  useImperativeHandle(forwardedRef, () => root.current, []);
8
8
  const generated = useId(), triggerId = `flood-collection-filters-${generated}`, searchId = `flood-collection-search-${generated}`;
@@ -21,6 +21,6 @@ export const CollectionQueryBar = /* @__PURE__ */ forwardRef(function Collection
21
21
  return;
22
22
  focusPersistent();
23
23
  onRemoveFilter(filter.id);
24
- } : undefined, children: filter.label }, filter.id))] }), (onClear || hasPanel) && _jsxs(Inline, { gap: "2", align: "center", className: "flood-collection-toolbar__commands", children: [onClear && _jsx(Button, { size: controlSize, variant: "outline", className: "flood-collection-toolbar__clear", "data-visible": canClear, disabled: disabled || !canClear, "aria-hidden": !canClear || undefined, onClick: () => { if (disabled || !canClear)
25
- return; focusPersistent(); onClear(); }, children: clearLabel }), hasPanel && _jsx(Popover, { title: filterTitle, ref: filterPanelRef, className: filterPanelClassName, open: filtersOpen, onOpenChange: onFiltersOpenChange, disabled: disabled, align: "end", trigger: trigger => _jsxs(Button, { ...trigger, id: triggerId, variant: "outline", size: controlSize, children: [_jsx(IconSlot, { size: controlSize, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M4 6h16M7 12h10m-7 6h4" }) }) }), filtersLabel] }), children: filterContent })] })] }) });
24
+ } : undefined, children: filter.label }, filter.id))] }), (onClear || hasPanel || actions) && _jsxs(Inline, { gap: "2", align: "center", className: "flood-collection-toolbar__commands", children: [onClear && _jsx(Button, { size: controlSize, variant: "outline", className: "flood-collection-toolbar__clear", "data-visible": canClear, disabled: disabled || !canClear, "aria-hidden": !canClear || undefined, onClick: () => { if (disabled || !canClear)
25
+ return; focusPersistent(); onClear(); }, children: clearLabel }), hasPanel && _jsx(Popover, { title: filterTitle, ref: filterPanelRef, className: filterPanelClassName, open: filtersOpen, onOpenChange: onFiltersOpenChange, disabled: disabled, align: "end", trigger: trigger => _jsxs(Button, { ...trigger, id: triggerId, variant: "outline", size: controlSize, children: [_jsx(IconSlot, { size: controlSize, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M4 6h16M7 12h10m-7 6h4" }) }) }), filtersLabel] }), children: filterContent }), actions] })] }) });
26
26
  });
@@ -1,11 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef } from "react";
2
+ import { forwardRef, useEffect, useRef } from "react";
3
3
  import { ScrollArea } from "./index.js";
4
4
  export const DescribedData = /* @__PURE__ */ forwardRef(function DescribedData({ items, layout, density, dividers, className, ...props }, ref) {
5
5
  if (items.some(item => typeof item.id !== "string" || !item.id.trim()) || new Set(items.map(item => item.id)).size !== items.length)
6
6
  throw new Error("Flood description items require unique nonempty IDs");
7
7
  return _jsx("dl", { ...props, ref: ref, className: ["flood-description-list", className].filter(Boolean).join(" "), "data-layout": layout, "data-density": density, "data-dividers": dividers, children: items.map(item => _jsxs("div", { className: "flood-description-list__item", children: [_jsx("dt", { children: item.term }), _jsx("dd", { children: item.description })] }, item.id)) });
8
8
  });
9
- export const TabularData = /* @__PURE__ */ forwardRef(function TabularData({ caption, children, hideCaption, density, striped, scrollLabel, cellVerticalAlign, className, hidden, dir, lang, ...props }, ref) {
10
- return _jsx("div", { className: "flood-table-container", hidden: hidden, dir: dir, lang: lang, children: _jsx(ScrollArea, { orientation: "horizontal", "aria-label": scrollLabel ?? caption, children: _jsxs("table", { ...props, ref: ref, hidden: hidden, dir: dir, lang: lang, className: ["flood-table", className].filter(Boolean).join(" "), "data-density": density, "data-striped": striped, "data-cell-vertical-align": cellVerticalAlign, children: [_jsx("caption", { className: hideCaption ? "flood-visually-hidden" : undefined, children: caption }), children] }) }) });
9
+ export const TabularData = /* @__PURE__ */ forwardRef(function TabularData({ caption, children, hideCaption, density, striped, scrollLabel, cellVerticalAlign, maxViewportHeight, stickyHeader, className, hidden, dir, lang, ...props }, ref) {
10
+ const viewport = useRef(null);
11
+ const scrollVertically = maxViewportHeight !== undefined;
12
+ const pinHeader = scrollVertically && stickyHeader;
13
+ useEffect(() => {
14
+ const node = viewport.current;
15
+ const header = node?.querySelector("table")?.tHead;
16
+ if (!pinHeader || !node || !header)
17
+ return;
18
+ const update = () => { node.style.scrollPaddingBlockStart = `${header.offsetHeight}px`; };
19
+ update();
20
+ const observer = new ResizeObserver(update);
21
+ observer.observe(header);
22
+ return () => {
23
+ observer.disconnect();
24
+ node.style.removeProperty("scroll-padding-block-start");
25
+ };
26
+ }, [pinHeader, children]);
27
+ return _jsx("div", { className: "flood-table-container", hidden: hidden, dir: dir, lang: lang, children: _jsx(ScrollArea, { ref: viewport, orientation: scrollVertically ? "both" : "horizontal", contentInset: scrollVertically ? "scrollbar" : undefined, tabIndex: scrollVertically ? 0 : undefined, style: scrollVertically ? { maxBlockSize: maxViewportHeight } : undefined, "aria-label": scrollLabel ?? caption, onFocus: pinHeader ? event => {
28
+ const target = event.target;
29
+ const header = event.currentTarget.querySelector("table")?.tHead;
30
+ if (header && target !== event.currentTarget && !header.contains(target) &&
31
+ !target.closest("[popover], dialog[open]") && target.getBoundingClientRect().top < header.getBoundingClientRect().bottom) {
32
+ target.scrollIntoView({ block: "nearest", inline: "nearest" });
33
+ }
34
+ } : undefined, children: _jsxs("table", { ...props, ref: ref, hidden: hidden, dir: dir, lang: lang, className: ["flood-table", className].filter(Boolean).join(" "), "data-density": density, "data-striped": striped, "data-cell-vertical-align": cellVerticalAlign, "data-sticky-header": pinHeader || undefined, children: [_jsx("caption", { className: hideCaption ? "flood-visually-hidden" : undefined, children: caption }), children] }) }) });
11
35
  });
@@ -0,0 +1,25 @@
1
+ import { type CSSProperties } from "react";
2
+ import { type DataTableColumn, type DataTableProps } from "./index.js";
3
+ export declare function useDataTableColumnLayout(columns: readonly DataTableColumn[], visibleColumns: readonly DataTableColumn[], { columnWidths, defaultColumnWidths, onColumnWidthsChange, measureViewport, }: Pick<DataTableProps, "columnWidths" | "defaultColumnWidths" | "onColumnWidthsChange"> & {
4
+ measureViewport?: boolean;
5
+ }): {
6
+ table: import("react").RefObject<HTMLTableElement | null>;
7
+ fixedWidth: number | undefined;
8
+ viewportWidth: number | undefined;
9
+ disclosureWidth: number | undefined;
10
+ atDefaults: boolean;
11
+ reset: () => void;
12
+ utilityCell: (id: string, header?: boolean) => {
13
+ "data-pin-edge": "end" | "start" | undefined;
14
+ "data-pin-boundary": true | undefined;
15
+ style: CSSProperties | undefined;
16
+ "data-data-table-utility": string;
17
+ };
18
+ columnCell: (column: DataTableColumn, header?: boolean) => {
19
+ "data-pin-edge": "end" | "start" | undefined;
20
+ "data-pin-boundary": true | undefined;
21
+ style: CSSProperties | undefined;
22
+ "data-data-table-column": string;
23
+ };
24
+ renderHandle: (column: DataTableColumn, label: string, disabled: boolean) => false | import("react").JSX.Element | undefined;
25
+ };
@@ -0,0 +1,271 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import { IconSlot } from "./index.js";
4
+ const defaultMinWidth = 64;
5
+ const defaultMaxWidth = 960;
6
+ const columnKey = (id) => `column:${id}`;
7
+ const utilityKey = (id) => `utility:${id}`;
8
+ const validWidth = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
9
+ function bounds(column) {
10
+ const min = validWidth(column.minWidth) ? Math.ceil(column.minWidth) : defaultMinWidth;
11
+ const max = validWidth(column.maxWidth) ? Math.floor(column.maxWidth) : defaultMaxWidth;
12
+ return { min, max: Math.max(min, max) };
13
+ }
14
+ function clampWidth(value, { min, max }) {
15
+ return Math.max(min, Math.min(max, Math.round(value)));
16
+ }
17
+ function normalizeWidths(columns, widths) {
18
+ return new Map(columns.flatMap(column => {
19
+ const value = widths && Object.prototype.hasOwnProperty.call(widths, column.id) ? widths[column.id] : undefined;
20
+ return column.resizable && validWidth(value) ? [[column.id, clampWidth(value, bounds(column))]] : [];
21
+ }));
22
+ }
23
+ function equalWidths(a, b) {
24
+ return a.size === b.size && [...a].every(([key, width]) => b.get(key) === width);
25
+ }
26
+ function measuredColumnWidth(header) {
27
+ const style = header.ownerDocument.defaultView?.getComputedStyle(header);
28
+ if (!style)
29
+ return header.offsetWidth;
30
+ const inlineSize = Number.parseFloat(style.inlineSize);
31
+ if (!Number.isFinite(inlineSize))
32
+ return header.offsetWidth;
33
+ const borderBox = style.boxSizing === "border-box" ? inlineSize : inlineSize +
34
+ Number.parseFloat(style.paddingInlineStart) + Number.parseFloat(style.paddingInlineEnd) +
35
+ Number.parseFloat(style.borderInlineStartWidth) + Number.parseFloat(style.borderInlineEndWidth);
36
+ // Avoid shaving a fraction of a pixel from untouched labels when switching to fixed tracks.
37
+ return Math.ceil(borderBox * 100) / 100;
38
+ }
39
+ function ColumnResizeHandle({ column, label, width, disabled, onPreview, onResize }) {
40
+ const gesture = useRef(null);
41
+ const [dragging, setDragging] = useState(false);
42
+ const limits = bounds(column);
43
+ const direction = (node) => {
44
+ const rtl = node.ownerDocument.defaultView?.getComputedStyle(node).direction === "rtl";
45
+ return (rtl ? -1 : 1) * (column.pinned === "end" ? -1 : 1);
46
+ };
47
+ const cancel = () => {
48
+ const active = gesture.current;
49
+ if (!active)
50
+ return;
51
+ gesture.current = null;
52
+ if (active.handle.hasPointerCapture(active.id))
53
+ active.handle.releasePointerCapture(active.id);
54
+ setDragging(false);
55
+ onPreview(undefined);
56
+ };
57
+ useEffect(() => {
58
+ if (disabled)
59
+ cancel();
60
+ }, [disabled]);
61
+ useEffect(() => () => {
62
+ if (gesture.current)
63
+ onPreview(undefined);
64
+ }, []);
65
+ useEffect(() => {
66
+ if (!dragging)
67
+ return;
68
+ const document = gesture.current?.handle.ownerDocument;
69
+ const view = document?.defaultView;
70
+ const hide = () => { if (document?.hidden)
71
+ cancel(); };
72
+ view?.addEventListener("blur", cancel);
73
+ document?.addEventListener("visibilitychange", hide);
74
+ return () => {
75
+ view?.removeEventListener("blur", cancel);
76
+ document?.removeEventListener("visibilitychange", hide);
77
+ };
78
+ }, [dragging]);
79
+ const requestedWidth = (event) => {
80
+ const active = gesture.current;
81
+ if (active?.id !== event.pointerId)
82
+ return undefined;
83
+ return clampWidth(active.width + (event.clientX - active.origin) * active.direction / active.scale, limits);
84
+ };
85
+ const value = width === undefined ? limits.min : clampWidth(width, limits);
86
+ return _jsx("div", { role: "separator", "aria-label": label, title: label, "aria-orientation": "vertical", "aria-valuemin": limits.min, "aria-valuemax": limits.max, "aria-valuenow": value, "aria-valuetext": `${value} px`, tabIndex: disabled ? -1 : 0, "aria-disabled": disabled || undefined, className: "flood-data-table__resize", "data-dragging": dragging || undefined, onKeyDown: event => {
87
+ if (event.defaultPrevented || disabled || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
88
+ return;
89
+ if (event.key === "Escape" && gesture.current) {
90
+ event.preventDefault();
91
+ cancel();
92
+ return;
93
+ }
94
+ if (gesture.current)
95
+ return;
96
+ let next;
97
+ if (event.key === "Home")
98
+ next = limits.min;
99
+ else if (event.key === "End")
100
+ next = limits.max;
101
+ else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
102
+ const step = event.shiftKey ? 24 : 8;
103
+ next = clampWidth((width ?? value) + (event.key === "ArrowRight" ? step : -step) * direction(event.currentTarget), limits);
104
+ }
105
+ else
106
+ return;
107
+ event.preventDefault();
108
+ if (next !== width)
109
+ onResize(next);
110
+ }, onPointerDown: event => {
111
+ const handle = event.currentTarget;
112
+ const header = handle.closest("th");
113
+ if (event.defaultPrevented || disabled || gesture.current || event.button !== 0 || !event.isPrimary || !header?.offsetWidth)
114
+ return;
115
+ try {
116
+ handle.setPointerCapture(event.pointerId);
117
+ }
118
+ catch {
119
+ return;
120
+ }
121
+ event.preventDefault();
122
+ handle.focus({ preventScroll: true });
123
+ gesture.current = {
124
+ handle, id: event.pointerId, origin: event.clientX, width: header.offsetWidth,
125
+ direction: direction(handle), scale: header.getBoundingClientRect().width / header.offsetWidth || 1,
126
+ };
127
+ setDragging(true);
128
+ }, onPointerMove: event => {
129
+ const next = requestedWidth(event);
130
+ if (next !== undefined && !disabled)
131
+ onPreview(event.clientX === gesture.current?.origin ? undefined : next);
132
+ }, onPointerUp: event => {
133
+ const next = requestedWidth(event);
134
+ if (next === undefined)
135
+ return;
136
+ const moved = event.clientX !== gesture.current?.origin && next !== gesture.current?.width;
137
+ cancel();
138
+ if (!disabled && moved)
139
+ onResize(next);
140
+ }, onPointerCancel: event => { if (gesture.current?.id === event.pointerId)
141
+ cancel(); }, onLostPointerCapture: event => { if (gesture.current?.id === event.pointerId)
142
+ cancel(); }, children: _jsx("span", { className: "flood-data-table__resize-icon", children: _jsx(IconSlot, { size: "sm", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M3 12h18m-14-4-4 4 4 4m10-8 4 4-4 4" }) }) }) }) });
143
+ }
144
+ export function useDataTableColumnLayout(columns, visibleColumns, { columnWidths, defaultColumnWidths, onColumnWidthsChange, measureViewport = false, }) {
145
+ const [local, setLocal] = useState(defaultColumnWidths);
146
+ const [draft, setDraft] = useState();
147
+ const [metrics, setMetrics] = useState({ widths: new Map(), utilityKeys: [], viewport: 0 });
148
+ const table = useRef(null);
149
+ const baseline = useRef(new Map());
150
+ const accepted = normalizeWidths(columns, columnWidths ?? local);
151
+ const defaults = normalizeWidths(columns, defaultColumnWidths);
152
+ const activeDraft = draft && visibleColumns.some(column => column.resizable && column.id === draft.id) ? draft : undefined;
153
+ const visibleSizes = new Map(visibleColumns.flatMap(column => {
154
+ const width = activeDraft?.id === column.id ? activeDraft.width : accepted.get(column.id);
155
+ return width === undefined ? [] : [[columnKey(column.id), width]];
156
+ }));
157
+ const active = measureViewport || columns.some(column => column.resizable || column.pinned);
158
+ const hasSizes = visibleSizes.size > 0;
159
+ const columnOrder = JSON.stringify(visibleColumns.map(column => column.id));
160
+ useLayoutEffect(() => {
161
+ const node = table.current;
162
+ const viewport = node?.closest(".flood-scroll-area__viewport");
163
+ const headerRow = node?.tHead?.rows[0];
164
+ if (!active || !node || !viewport || !headerRow)
165
+ return;
166
+ let headers = Array.from(headerRow.cells);
167
+ const measure = () => {
168
+ const widths = new Map();
169
+ const utilityKeys = [];
170
+ for (const header of headers) {
171
+ const id = header.dataset.dataTableColumn;
172
+ const utility = header.dataset.dataTableUtility;
173
+ const key = id !== undefined ? columnKey(id) : utilityKey(utility ?? `unknown-${header.cellIndex}`);
174
+ if (id === undefined)
175
+ utilityKeys.push(key);
176
+ if (header.offsetWidth === 0)
177
+ continue;
178
+ widths.set(key, header.offsetWidth);
179
+ if (!hasSizes || !baseline.current.has(key))
180
+ baseline.current.set(key, measuredColumnWidth(header));
181
+ }
182
+ setMetrics(previous => previous.viewport === viewport.clientWidth && equalWidths(previous.widths, widths) &&
183
+ previous.utilityKeys.join("|") === utilityKeys.join("|")
184
+ ? previous : { widths, utilityKeys, viewport: viewport.clientWidth });
185
+ };
186
+ measure();
187
+ const Observer = node.ownerDocument.defaultView?.ResizeObserver;
188
+ if (!Observer)
189
+ return;
190
+ const observer = new Observer(measure);
191
+ const observeHeaders = () => {
192
+ observer.disconnect();
193
+ headers = Array.from(headerRow.cells);
194
+ observer.observe(viewport);
195
+ for (const header of headers)
196
+ observer.observe(header, { box: "border-box" });
197
+ measure();
198
+ };
199
+ observeHeaders();
200
+ const HeaderObserver = node.ownerDocument.defaultView?.MutationObserver;
201
+ const headerObserver = HeaderObserver && new HeaderObserver(observeHeaders);
202
+ headerObserver?.observe(headerRow, { childList: true });
203
+ return () => {
204
+ observer.disconnect();
205
+ headerObserver?.disconnect();
206
+ };
207
+ }, [active, hasSizes, columnOrder]);
208
+ const sizes = hasSizes ? new Map([...baseline.current, ...visibleSizes]) : metrics.widths;
209
+ const keys = [...metrics.utilityKeys, ...visibleColumns.map(column => columnKey(column.id))];
210
+ const fixedWidth = hasSizes && keys.every(key => sizes.has(key))
211
+ ? keys.reduce((total, key) => total + sizes.get(key), 0)
212
+ : undefined;
213
+ const start = visibleColumns.filter(column => column.pinned === "start").map(column => columnKey(column.id));
214
+ const end = visibleColumns.filter(column => column.pinned === "end").map(column => columnKey(column.id)).reverse();
215
+ if (start.length)
216
+ start.unshift(...metrics.utilityKeys);
217
+ const pinned = new Map();
218
+ const pinnedKeys = [...start, ...end];
219
+ const pinnedWidth = pinnedKeys.reduce((total, key) => total + (metrics.widths.get(key) ?? 0), 0);
220
+ if (pinnedKeys.every(key => metrics.widths.has(key)) && pinnedWidth + defaultMinWidth <= metrics.viewport) {
221
+ for (const [edge, group] of [["start", start], ["end", end]]) {
222
+ let offset = 0;
223
+ group.forEach((key, index) => {
224
+ pinned.set(key, { edge, offset, boundary: index === group.length - 1 });
225
+ offset += metrics.widths.get(key);
226
+ });
227
+ }
228
+ }
229
+ function cell(key, header, width) {
230
+ const pin = pinned.get(key);
231
+ const inlineSize = header ? (fixedWidth === undefined ? width : sizes.get(key)) : undefined;
232
+ return {
233
+ "data-pin-edge": pin?.edge,
234
+ "data-pin-boundary": pin?.boundary || undefined,
235
+ style: inlineSize !== undefined || pin
236
+ ? { inlineSize, "--flood-data-table-pin-offset": pin ? `${pin.offset}px` : undefined }
237
+ : undefined,
238
+ };
239
+ }
240
+ function publish(next) {
241
+ if (equalWidths(accepted, next))
242
+ return;
243
+ const widths = Object.fromEntries(next);
244
+ if (columnWidths === undefined)
245
+ setLocal(widths);
246
+ onColumnWidthsChange?.(widths);
247
+ }
248
+ return {
249
+ table, fixedWidth,
250
+ viewportWidth: metrics.viewport || undefined,
251
+ disclosureWidth: metrics.widths.get(utilityKey("expansion")),
252
+ atDefaults: equalWidths(accepted, defaults),
253
+ reset: () => { setDraft(undefined); publish(defaults); },
254
+ utilityCell: (id, header = false) => ({
255
+ ...cell(utilityKey(id), header),
256
+ "data-data-table-utility": id,
257
+ }),
258
+ columnCell: (column, header = false) => ({
259
+ ...cell(columnKey(column.id), header, header ? (accepted.get(column.id) ?? column.width) : undefined),
260
+ "data-data-table-column": column.id,
261
+ }),
262
+ renderHandle: (column, label, disabled) => column.resizable &&
263
+ _jsx(ColumnResizeHandle, { column: column, label: label, disabled: disabled, width: visibleSizes.get(columnKey(column.id)) ?? metrics.widths.get(columnKey(column.id)), onPreview: width => setDraft(previous => {
264
+ if (width === undefined)
265
+ return previous?.id === column.id ? undefined : previous;
266
+ if (previous?.id === column.id && previous.width === width)
267
+ return previous;
268
+ return { id: column.id, width };
269
+ }), onResize: width => publish(new Map(accepted).set(column.id, width)) }),
270
+ };
271
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode, type TdHTMLAttributes } from "react";
2
+ import { type DataTableLabels, type DataTableRow } from "./index.js";
3
+ export declare function useDataTableExpansion(rows: readonly DataTableRow[], expandedIds: readonly string[] | undefined, defaultExpandedIds: readonly string[] | undefined, onExpandedChange: ((ids: string[]) => void) | undefined): {
4
+ enabled: boolean;
5
+ expanded: Set<string>;
6
+ toggle: (id: string) => void;
7
+ };
8
+ interface DataTableExpandableRowProps {
9
+ row: DataTableRow;
10
+ showDisclosure: boolean;
11
+ expanded: boolean;
12
+ colSpan: number;
13
+ labels: DataTableLabels;
14
+ onToggle: () => void;
15
+ disclosureCellProps?: TdHTMLAttributes<HTMLTableCellElement>;
16
+ viewportWidth?: number;
17
+ disclosureWidth?: number;
18
+ children: (disclosureCell: ReactNode) => ReactNode;
19
+ }
20
+ export declare function DataTableExpandableRow({ row, showDisclosure, expanded, colSpan, labels, onToggle, disclosureCellProps, viewportWidth, disclosureWidth, children }: DataTableExpandableRowProps): import("react").JSX.Element;
21
+ export {};
@@ -0,0 +1,79 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ import { IconButton } from "./index.js";
4
+ import { useLayerExitContent } from "./overlay-runtime.js";
5
+ function hasExpandedContent(row) {
6
+ return row.expandedContent != null && typeof row.expandedContent !== "boolean";
7
+ }
8
+ export function useDataTableExpansion(rows, expandedIds, defaultExpandedIds, onExpandedChange) {
9
+ const [localExpandedIds, setLocalExpandedIds] = useState(defaultExpandedIds ?? []);
10
+ const expanded = new Set(expandedIds ?? localExpandedIds);
11
+ const enabled = expandedIds !== undefined || defaultExpandedIds !== undefined || rows.some(hasExpandedContent);
12
+ function toggle(id) {
13
+ const next = new Set(expanded);
14
+ if (next.has(id))
15
+ next.delete(id);
16
+ else
17
+ next.add(id);
18
+ const ids = [...next];
19
+ if (expandedIds === undefined)
20
+ setLocalExpandedIds(ids);
21
+ onExpandedChange?.(ids);
22
+ }
23
+ return { enabled, expanded, toggle };
24
+ }
25
+ export function DataTableExpandableRow({ row, showDisclosure, expanded, colSpan, labels, onToggle, disclosureCellProps, viewportWidth, disclosureWidth, children }) {
26
+ const detailId = useId();
27
+ const toggle = useRef(null);
28
+ const reveal = useRef(null);
29
+ const detail = useRef(null);
30
+ const lastFocusedDetail = useRef(null);
31
+ const expandable = showDisclosure && hasExpandedContent(row);
32
+ const open = expandable && expanded;
33
+ const showDetail = useLayerExitContent(reveal, open, expandable, "grid-template-rows");
34
+ useEffect(() => {
35
+ const content = detail.current;
36
+ const header = content?.querySelector("table")?.tHead?.rows[0];
37
+ if (!content || !header)
38
+ return;
39
+ const alignBranch = () => {
40
+ const bounds = content.getBoundingClientRect();
41
+ if (!bounds.height)
42
+ return;
43
+ const heading = header.getBoundingClientRect();
44
+ const center = (heading.top + heading.height / 2 - bounds.top) * content.offsetHeight / bounds.height;
45
+ const value = `${center}px`;
46
+ if (content.style.getPropertyValue("--flood-data-table-branch-target") !== value) {
47
+ content.style.setProperty("--flood-data-table-branch-target", value);
48
+ }
49
+ };
50
+ alignBranch();
51
+ const observer = new ResizeObserver(alignBranch);
52
+ observer.observe(content);
53
+ observer.observe(header);
54
+ return () => {
55
+ observer.disconnect();
56
+ content.style.removeProperty("--flood-data-table-branch-target");
57
+ };
58
+ }, [showDetail, row.expandedContent]);
59
+ useEffect(() => {
60
+ if (open)
61
+ return;
62
+ const previousFocus = lastFocusedDetail.current;
63
+ lastFocusedDetail.current = null;
64
+ const button = toggle.current;
65
+ const active = button?.ownerDocument.activeElement;
66
+ if (button && previousFocus && (active === button.ownerDocument.body || reveal.current?.contains(active ?? null))) {
67
+ button.focus({ preventScroll: true });
68
+ }
69
+ }, [open]);
70
+ const label = row.label ?? row.primaryAction?.title ?? row.id;
71
+ const actionLabel = open ? labels.collapseRow ?? (name => `Collapse ${name}`) : labels.expandRow ?? (name => `Expand ${name}`);
72
+ return _jsxs(_Fragment, { children: [children(showDisclosure ? _jsx("td", { ...disclosureCellProps, "data-data-table-utility": "expansion", "data-detail-visible": showDetail || undefined, className: "flood-data-table__expand", children: expandable && _jsx(IconButton, { ref: toggle, label: actionLabel(label), size: "sm", iconSize: "xs", variant: "ghost", className: "flood-data-table__disclosure", type: "button", "aria-expanded": open, "aria-controls": open ? detailId : undefined, onClick: onToggle, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m6 9 6 6 6-6" }) }) }) }) : null), showDetail && _jsx("tr", { id: detailId, className: "flood-data-table__expanded-row", "aria-hidden": open ? undefined : true, onFocusCapture: event => {
73
+ if (event.currentTarget.contains(event.target))
74
+ lastFocusedDetail.current = event.target;
75
+ }, children: _jsx("td", { colSpan: colSpan, children: _jsx("div", { ref: reveal, className: "flood-data-table__detail-reveal", "data-open": open || undefined, children: _jsx("div", { className: "flood-data-table__detail-clip", children: _jsx("div", { ref: detail, className: "flood-data-table__expanded-content", style: {
76
+ maxInlineSize: viewportWidth ? `max(0px, calc(${viewportWidth}px - 2 * var(--flood-data-table-edge) - var(--flood-scroll-content-inline, 0px)))` : undefined,
77
+ "--flood-data-table-disclosure-center": disclosureWidth === undefined ? undefined : `${disclosureWidth / 2}px`,
78
+ }, children: row.expandedContent }) }) }) }) })] });
79
+ }