@eifi1/ui-kit 0.48.0 → 0.48.2
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 +4 -3
- package/dist/components/data-table-fit.d.ts +8 -1
- package/dist/components/data-table-fit.js +7 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-groups.d.ts +3 -1
- package/dist/components/data-table-groups.js +11 -5
- package/dist/components/data-table-groups.js.map +1 -1
- package/dist/components/data-table-head.d.ts +8 -1
- package/dist/components/data-table-head.js +11 -3
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-leading-row.d.ts +3 -1
- package/dist/components/data-table-leading-row.js +2 -1
- package/dist/components/data-table-leading-row.js.map +1 -1
- package/dist/components/data-table-row-actions.js +2 -2
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-row.d.ts +84 -0
- package/dist/components/data-table-row.js +261 -0
- package/dist/components/data-table-row.js.map +1 -0
- package/dist/components/data-table-selection.d.ts +3 -0
- package/dist/components/data-table-selection.js +12 -1
- package/dist/components/data-table-selection.js.map +1 -1
- package/dist/components/data-table-virtual.d.ts +98 -0
- package/dist/components/data-table-virtual.js +369 -0
- package/dist/components/data-table-virtual.js.map +1 -0
- package/dist/components/data-table.d.ts +58 -2
- package/dist/components/data-table.js +227 -230
- package/dist/components/data-table.js.map +1 -1
- package/dist/data-table.d.ts +1 -0
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/hooks/use-anchored-panel.js +2 -1
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/shell/app-shell.js +7 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/package.json +1 -1
- package/src/components/data-table-fit.ts +17 -2
- package/src/components/data-table-groups.tsx +15 -4
- package/src/components/data-table-head.tsx +25 -2
- package/src/components/data-table-leading-row.tsx +4 -1
- package/src/components/data-table-row-actions.tsx +3 -2
- package/src/components/data-table-row.tsx +483 -0
- package/src/components/data-table-selection.ts +17 -1
- package/src/components/data-table-virtual.ts +547 -0
- package/src/components/data-table.tsx +326 -327
- package/src/data-table.ts +2 -0
- package/src/hooks/use-anchored-panel.ts +9 -1
- package/src/shell/app-shell.tsx +7 -1
package/dist/data-table.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { DataTable, DEFAULT_DATA_TABLE_SORT_LABELS } from "./components/data-tab
|
|
|
7
7
|
export type { DataTableSortLabels, DataTableColumn, DataTableCellProps, DataTableHeadProps, DataTableProps, ServerPagination, FilterState, MobileSwipeActions, DataTableDensity, DataTableChrome, DataTableRowAction, DataTableDesktopSwipeActions, } from "./components/data-table.js";
|
|
8
8
|
export { swipePlanRowActions } from "./components/data-table-row-actions.js";
|
|
9
9
|
export type { DataTableGroupBy, DataTableLoadMore } from "./components/data-table-groups.js";
|
|
10
|
+
export type { DataTableVirtualize } from "./components/data-table-virtual.js";
|
|
10
11
|
export * from "./components/data-table-cells.js";
|
|
11
12
|
export { useTableUrlState, readTableUrlState } from "./components/use-table-state.js";
|
|
12
13
|
export type { TableUrlState, TableUrlSync, TableUrlStateProps, UseTableUrlStateOptions, UseTableUrlStateReturn, } from "./components/use-table-state.js";
|
package/dist/data-table.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/data-table.ts"],"sourcesContent":["// `@eifi1/ui-kit/data-table` — the table suite.\n//\n// A re-slicing of the main barrel, not a new API. This entry point requires\n// `react-router`: DataTable calls useSearchParams unconditionally, above any `urlSync`\n// branch, so a Router is needed even with URL sync switched off.\nexport * from \"./components/data-table-labels\";\nexport * from \"./components/data-table-sort\";\nexport * from \"./components/data-table-filters\";\nexport * from \"./components/data-table-pagination\";\nexport { FilterPopover } from \"./components/data-table-filter-popover\";\n// `SortState` comes from data-table-sort above; re-exporting the module wholesale\n// would collide with it, which is why the main barrel names these explicitly too.\nexport { DataTable, DEFAULT_DATA_TABLE_SORT_LABELS } from \"./components/data-table\";\nexport type {\n DataTableSortLabels,\n DataTableColumn,\n // The barrel had these two and this entry did not (refactor audit 2026-10, MOD-5):\n // the types of a column's `cellProps` and `headProps`.\n DataTableCellProps,\n DataTableHeadProps,\n DataTableProps,\n ServerPagination,\n FilterState,\n MobileSwipeActions,\n DataTableDensity,\n DataTableChrome,\n DataTableRowAction,\n DataTableDesktopSwipeActions,\n} from \"./components/data-table\";\n// 0.46.1 (keksdose N3): a swipe plan as `RowActions`' actions — the desktop column's own\n// derivation, for an app's hand-built actions column.\nexport { swipePlanRowActions } from \"./components/data-table-row-actions\";\n// 0.48 (keksdose dev #605): a `groupBy` level (also `mobileGroupBy`'s) and the `loadMore` footer.\nexport type { DataTableGroupBy, DataTableLoadMore } from \"./components/data-table-groups\";\nexport * from \"./components/data-table-cells\";\nexport { useTableUrlState, readTableUrlState } from \"./components/use-table-state\";\nexport type {\n TableUrlState,\n TableUrlSync,\n TableUrlStateProps,\n UseTableUrlStateOptions,\n UseTableUrlStateReturn,\n} from \"./components/use-table-state\";\n"],"mappings":";AAKA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAAS,qBAAqB;AAG9B,SAAS,WAAW,sCAAsC;AAmB1D,SAAS,2BAA2B;
|
|
1
|
+
{"version":3,"sources":["../src/data-table.ts"],"sourcesContent":["// `@eifi1/ui-kit/data-table` — the table suite.\n//\n// A re-slicing of the main barrel, not a new API. This entry point requires\n// `react-router`: DataTable calls useSearchParams unconditionally, above any `urlSync`\n// branch, so a Router is needed even with URL sync switched off.\nexport * from \"./components/data-table-labels\";\nexport * from \"./components/data-table-sort\";\nexport * from \"./components/data-table-filters\";\nexport * from \"./components/data-table-pagination\";\nexport { FilterPopover } from \"./components/data-table-filter-popover\";\n// `SortState` comes from data-table-sort above; re-exporting the module wholesale\n// would collide with it, which is why the main barrel names these explicitly too.\nexport { DataTable, DEFAULT_DATA_TABLE_SORT_LABELS } from \"./components/data-table\";\nexport type {\n DataTableSortLabels,\n DataTableColumn,\n // The barrel had these two and this entry did not (refactor audit 2026-10, MOD-5):\n // the types of a column's `cellProps` and `headProps`.\n DataTableCellProps,\n DataTableHeadProps,\n DataTableProps,\n ServerPagination,\n FilterState,\n MobileSwipeActions,\n DataTableDensity,\n DataTableChrome,\n DataTableRowAction,\n DataTableDesktopSwipeActions,\n} from \"./components/data-table\";\n// 0.46.1 (keksdose N3): a swipe plan as `RowActions`' actions — the desktop column's own\n// derivation, for an app's hand-built actions column.\nexport { swipePlanRowActions } from \"./components/data-table-row-actions\";\n// 0.48 (keksdose dev #605): a `groupBy` level (also `mobileGroupBy`'s) and the `loadMore` footer.\nexport type { DataTableGroupBy, DataTableLoadMore } from \"./components/data-table-groups\";\n// 0.48.2 (keksdose dev #632): `virtualize`'s options.\nexport type { DataTableVirtualize } from \"./components/data-table-virtual\";\nexport * from \"./components/data-table-cells\";\nexport { useTableUrlState, readTableUrlState } from \"./components/use-table-state\";\nexport type {\n TableUrlState,\n TableUrlSync,\n TableUrlStateProps,\n UseTableUrlStateOptions,\n UseTableUrlStateReturn,\n} from \"./components/use-table-state\";\n"],"mappings":";AAKA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAAS,qBAAqB;AAG9B,SAAS,WAAW,sCAAsC;AAmB1D,SAAS,2BAA2B;AAKpC,cAAc;AACd,SAAS,kBAAkB,yBAAyB;","names":[]}
|
package/dist/eslint/index.js
CHANGED
|
@@ -47,7 +47,7 @@ const roleUtilities = {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
|
-
const VERSION = true ? "0.48.
|
|
50
|
+
const VERSION = true ? "0.48.2" : "0.0.0-source";
|
|
51
51
|
const recommended = {
|
|
52
52
|
name: "@eifi1/ui-kit/recommended",
|
|
53
53
|
// Not the tests, as check:tokens skips the kit's: a test names the old class to assert it
|
|
@@ -13,6 +13,7 @@ function readKeyboardInset({ minimum = MIN_KEYBOARD_PX } = {}) {
|
|
|
13
13
|
const inset = Math.round(window.innerHeight - vv.height - vv.offsetTop);
|
|
14
14
|
return inset >= minimum ? inset : 0;
|
|
15
15
|
}
|
|
16
|
+
const UNMEASURED_VIEWPORT = { top: 0, height: 0 };
|
|
16
17
|
function readViewport() {
|
|
17
18
|
if (typeof window === "undefined") return { top: 0, height: 0 };
|
|
18
19
|
const vv = window.visualViewport;
|
|
@@ -89,7 +90,7 @@ function useVisualViewport(active) {
|
|
|
89
90
|
}
|
|
90
91
|
function useAnchoredPanel(ref, open, options = {}) {
|
|
91
92
|
const rect = useAnchoredRect(ref, open);
|
|
92
|
-
const [viewport, setViewport] = useState(
|
|
93
|
+
const [viewport, setViewport] = useState(UNMEASURED_VIEWPORT);
|
|
93
94
|
const { scale } = useTextSize();
|
|
94
95
|
const preferredHeight = (options.preferredHeight ?? 320) * scale;
|
|
95
96
|
const minHeight = (options.minHeight ?? 160) * scale;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-anchored-panel.ts"],"sourcesContent":["import { useLayoutEffect, useState } from \"react\";\nimport type { RefObject } from \"react\";\nimport { useAnchoredRect, type AnchorRect } from \"./use-anchored-rect\";\nimport { useTextSize } from \"../theme/text-size\";\nimport type { Direction } from \"../lib/direction\";\n\n/**\n * Vertical placement for a portalled, `position: fixed` panel anchored to a trigger.\n *\n * The problem it solves (feedback #135): on a phone, opening a picker focuses its\n * search box, the on-screen keyboard slides up, and a panel pinned below a trigger in\n * the lower half of the screen ends up entirely behind that keyboard — with nothing\n * the user can do about it, because `position: fixed` does not scroll.\n *\n * The keyboard is invisible to the usual measurements: on Android Chrome the LAYOUT\n * viewport (`window.innerHeight`, and the origin `getBoundingClientRect` and\n * `position: fixed` are relative to) does not shrink when it opens. Only\n * `visualViewport` reports the region actually on screen, so that is what this reads.\n */\n\n/** The on-screen region, in layout-viewport coordinates. */\nexport interface ViewportBox {\n top: number;\n height: number;\n /**\n * The height of the LAYOUT viewport itself, which `position: fixed`'s `bottom` is\n * measured from. Only {@link anchoredPanelPlacement} reads it, for a flipped panel's\n * `bottom`. Defaults to `top + height`: the two are the same box whenever no keyboard\n * and no zoom shrink the visible region.\n */\n layoutHeight?: number;\n}\n\n/**\n * Whether the page is pinch-zoomed (docs/text-size-harmonization.md §10.2). A zoom\n * shrinks `visualViewport.height` exactly like an on-screen keyboard does, so every\n * reader below must tell the two apart — and while zoomed, the answer is \"no keyboard\":\n * following a zoomed viewport would drag a pinned row on every pan, magnified, and the\n * browser already scrolls a focused field into view. The epsilon absorbs a scale of\n * 1.0000001 that some engines report at rest.\n */\nfunction isZoomed(vv: VisualViewport): boolean {\n return vv.scale > 1.001;\n}\n\n/** Below this the \"keyboard\" is rounding noise or a browser UI strip — `innerHeight` and\n * `visualViewport.height` routinely disagree by a pixel or two — and nothing should move\n * (keksdose's `MIN_KEYBOARD_PX`). */\nconst MIN_KEYBOARD_PX = 100;\n\nexport interface KeyboardInsetOptions {\n /** The smallest inset that counts as a keyboard. Default 100 px. */\n minimum?: number;\n}\n\n/**\n * How many CSS px at the bottom of the LAYOUT viewport the on-screen keyboard covers, now\n * — the one guarded reader (§10.2), so the apps' copies go (keksdose's\n * `use-keyboard-inset.ts`):\n *\n * - 0 while the page is pinch-zoomed (`visualViewport.scale > 1`), where a smaller visual\n * viewport is the zoom, not a keyboard;\n * - 0 below `minimum` (100 px), which is noise;\n * - 0 without `visualViewport` (SSR, jsdom, an old browser) and under\n * `interactive-widget=resizes-content`, where `bottom: 0` already lands above the\n * keyboard.\n *\n * Read it on `visualViewport`'s `resize` and `scroll` events (window `resize` as the\n * fallback); a bottom-anchored control lifts itself by the result.\n */\nexport function readKeyboardInset({ minimum = MIN_KEYBOARD_PX }: KeyboardInsetOptions = {}): number {\n if (typeof window === \"undefined\") return 0;\n const vv = window.visualViewport;\n if (!vv || isZoomed(vv)) return 0;\n const inset = Math.round(window.innerHeight - vv.height - vv.offsetTop);\n return inset >= minimum ? inset : 0;\n}\n\nfunction readViewport(): ViewportBox {\n if (typeof window === \"undefined\") return { top: 0, height: 0 };\n const vv = window.visualViewport;\n // The layout viewport's height is `innerHeight`, the reference `readKeyboardInset`\n // measures the keyboard against. The keyboard leaves it alone (it shrinks only the\n // VISUAL viewport; under `interactive-widget=resizes-content` it shrinks both, the\n // fixed box with them), and it follows the mobile URL bar as it slides away, as a\n // fixed box's `bottom` does. Not `documentElement.clientHeight`: on mobile Chrome and\n // Safari that stays at the small, URL-bar-shown height, so with the bar scrolled away\n // a flipped panel would sit that much too low, over its own field. `innerHeight`'s one\n // error is a desktop page with a horizontal scrollbar, which it counts and the fixed\n // box does not: the gap widens by the bar's thickness, and never closes.\n const layoutHeight = window.innerHeight;\n // offsetTop, not 0: with the keyboard up the user can also pan the visual viewport\n // within the layout viewport, which shifts where \"visible\" starts.\n return vv\n ? { top: vv.offsetTop, height: vv.height, layoutHeight }\n : { top: 0, height: window.innerHeight, layoutHeight };\n}\n\nexport interface AnchoredPanelOptions {\n /** Space between trigger and panel. */\n gap?: number;\n /** Space kept clear at the viewport edges. */\n margin?: number;\n /** The height the panel would like, when there is room. In px at Normal text size:\n * {@link useAnchoredPanel} multiplies it by the size's scale, since what a panel\n * holds is rows of text (0.32, docs/text-size-harmonization.md §3.2). */\n preferredHeight?: number;\n /** Below this, the space under the trigger counts as unusable and a flip is considered.\n * In px at Normal, scaled likewise. */\n minHeight?: number;\n}\n\n/**\n * `top` and `bottom` are the two edges of a panel exactly `maxHeight` tall. A panel is\n * sized by its content and `maxHeight` only caps it, so apply the edge on the trigger's\n * side, the one that holds whatever the height: `top` while the panel is below,\n * `bottom` (and no `top`) once it is flipped `above`.\n */\nexport interface AnchoredPanel {\n /** The trigger's rect, or null while closed/unmeasured. */\n rect: AnchorRect | null;\n /** Viewport-space `top` for the panel — the one to apply while it is below. Flipped,\n * it is where a FULL-height panel's top would be, and a three-row list placed by it\n * floated far above its field (keksdose K1, live #401); place that one by `bottom`. */\n top: number;\n /** CSS `bottom` for a `position: fixed` panel: the distance from the layout\n * viewport's bottom edge up to the panel's. The one to apply once it is flipped\n * `above`: it keeps the panel's foot `gap` above the trigger however short its\n * content, and while a filtered list shrinks or grows under the user's typing. */\n bottom: number;\n /** Cap that keeps the panel inside the VISIBLE viewport — apply as `maxHeight`. */\n maxHeight: number;\n /** Whether the panel was flipped above the trigger. */\n above: boolean;\n}\n\n/** Absolute floor: below this a list is useless, so overflow the margin instead. */\nconst FLOOR = 96;\n\n/** How much more room above must offer, when neither side fits the preferred height,\n * before the panel changes sides — so it never jumps for a few pixels' gain. */\nconst FLIP_GAIN = 48;\n\n/**\n * The pure half of {@link useAnchoredPanel}: where a panel goes, given the trigger's\n * rect and the region actually on screen.\n *\n * Prefers below the trigger — the conventional direction, and the one that keeps the\n * trigger's own value visible — whenever the panel's preferred height fits there. When it\n * does not, the panel opens above if the full height fits there, or if above is clearly\n * roomier (by {@link FLIP_GAIN}); below an unusable `minHeight` it flips to any roomier\n * side, as before. It used to flip only below `minHeight`, so a select with 160–319 px\n * under it opened downward as a cramped list with 600 px free above (keksdose live #384,\n * the currency select of the new-budget form near the bottom of the screen). The\n * returned `maxHeight` is what makes the flip sufficient rather than merely different:\n * without it a tall panel placed above just runs off the top instead of the bottom.\n *\n * A flipped panel hangs from its BOTTOM edge, `gap` above the trigger, and is placed by\n * `bottom` (keksdose K1, live #401). It used to be placed by a `top` worked out as\n * though the panel were `maxHeight` tall, and a panel is only as tall as its content:\n * a three-row address list under a 256 px cap sat ~176 px above its field, on a phone\n * with the keyboard up. Only when even the {@link FLOOR} does not fit above does that\n * edge give way, as `top` always did, so the panel's head (a search box, often) stays\n * on screen rather than above it.\n *\n * Split out so the geometry is testable without a DOM and a fake keyboard — @hb/ui has\n * no test runner of its own, so this is exercised from the consuming app.\n */\nexport function anchoredPanelPlacement(\n rect: Pick<AnchorRect, \"top\" | \"bottom\">,\n viewport: ViewportBox,\n { gap = 4, margin = 8, preferredHeight = 320, minHeight = 160 }: AnchoredPanelOptions = {},\n): Omit<AnchoredPanel, \"rect\"> {\n const viewTop = viewport.top + margin;\n const viewBottom = viewport.top + viewport.height - margin;\n const spaceBelow = viewBottom - (rect.bottom + gap);\n const spaceAbove = rect.top - gap - viewTop;\n const above =\n spaceAbove > spaceBelow &&\n (spaceBelow < minHeight ||\n (spaceBelow < preferredHeight && (spaceAbove >= preferredHeight || spaceAbove - spaceBelow >= FLIP_GAIN)));\n const space = Math.max(FLOOR, above ? spaceAbove : spaceBelow);\n const maxHeight = Math.min(preferredHeight, space);\n // Unchanged, for the apps that call the hook and apply it: the edge to use below, and\n // a full-height panel's top above. In layout-viewport space, like the rect.\n const top = above ? Math.max(viewTop, rect.top - gap - maxHeight) : rect.bottom + gap;\n // `bottom` counts up from the LAYOUT viewport's bottom edge, not the visible region's:\n // `position: fixed` is laid out in the layout viewport, which runs on behind the\n // keyboard, exactly as the rect is measured in it. The keyboard, the pan and the zoom\n // are already in `maxHeight`, which keeps the panel's top inside what is on screen.\n const layoutHeight = viewport.layoutHeight ?? viewport.top + viewport.height;\n return { top, bottom: layoutHeight - (top + maxHeight), maxHeight, above };\n}\n\n/**\n * The glass a panel has to stay on, in CSS pixels — `window.innerWidth` is the wrong\n * width on a phone (keksdose run 72, live #381). There it is the LAYOUT viewport, and\n * that grows to the page's width as soon as anything sticks out past the screen: 430 on\n * a 406px screen in Chromium's mobile emulation, with `position: fixed` layers laid out\n * against the 430. A panel that had widened the page then measured itself as inside it.\n * The root element's client width is the initial containing block instead — the screen,\n * the width `100vw` and the media queries see — whatever overflows, and on a desktop it\n * also leaves a classic scrollbar out, which is where a fixed box's `right` is measured\n * from. It is 0 where nothing is laid out (jsdom), and the window is the answer there.\n * The height stays the window's: nothing measured grows it, and a phone's collapsing\n * toolbar makes the window the truer of the two.\n *\n * Tooltip's lesson, moved here so every anchored panel reads the same width (round 0.34\n * Wave 2, DUP-7).\n */\nexport function viewportSize(): { width: number; height: number } {\n return {\n width: document.documentElement.clientWidth || window.innerWidth,\n height: window.innerHeight,\n };\n}\n\nexport interface AnchoredInlineOptions {\n /** The trigger's reading direction, which `start`, `end` and `after` follow. Default\n * `ltr`. A portalled panel has left the subtree it would inherit `dir` from, so read\n * it off the trigger (`dirOf`). */\n dir?: Direction;\n /**\n * Which of the panel's edges lines up with which of the trigger's:\n * - `start` / `end` — the panel's start (end) edge on the trigger's start (end) edge,\n * by reading direction: a picker hangs from its field's start, a popover from its\n * button's end;\n * - `left` / `right` — the same, physically;\n * - `after` — the panel's start edge on the trigger's END edge: beside it, as the\n * sidebar's flyout opens.\n *\n * Default `start`.\n */\n align?: \"start\" | \"end\" | \"left\" | \"right\" | \"after\";\n /** The room kept from either edge of the glass, in px. Default 8. */\n edge?: number;\n /** The glass's width. Default {@link viewportSize}'s. */\n viewportWidth?: number;\n}\n\n/** The horizontal place of a `position: fixed` panel: its `left`, and the `right` of the\n * same box for a panel hung from its right edge (both in px, from the glass's edges). */\nexport interface InlinePlacement {\n left: number;\n right: number;\n}\n\n/**\n * The horizontal half of an anchored panel, beside {@link anchoredPanelPlacement}'s\n * vertical one (round 0.34 Wave 2, DUP-7): where a panel `panelWidth` wide goes against\n * the trigger's `rect`, kept `edge` px inside the glass ({@link viewportSize}).\n *\n * Popover, DropdownPanel, ComboboxPanel, HoverMenu and the AppShell flyout each wrote\n * this themselves, against three different widths: `innerWidth`, which on a phone is\n * the grown layout viewport and on a desktop counts the scrollbar; the root's client\n * width; and, for the entity picker's panel in a left-to-right form, no clamp at all.\n *\n * A panel wider than the room keeps its START on the glass — its left edge in a\n * left-to-right form, its right in a right-to-left one — as Tooltip's slide does: the\n * start of a row of text is the half worth keeping.\n */\nexport function anchoredInline(\n rect: Pick<AnchorRect, \"left\" | \"right\">,\n panelWidth: number,\n { dir = \"ltr\", align = \"start\", edge = 8, viewportWidth = viewportSize().width }: AnchoredInlineOptions = {},\n): InlinePlacement {\n const rtl = dir === \"rtl\";\n // Where the panel's LEFT edge goes, before the clamp.\n let wanted: number;\n switch (align) {\n case \"left\":\n wanted = rect.left;\n break;\n case \"right\":\n wanted = rect.right - panelWidth;\n break;\n case \"start\":\n wanted = rtl ? rect.right - panelWidth : rect.left;\n break;\n case \"end\":\n wanted = rtl ? rect.left : rect.right - panelWidth;\n break;\n case \"after\":\n wanted = rtl ? rect.left - panelWidth : rect.right;\n break;\n }\n const min = edge;\n const max = viewportWidth - edge - panelWidth;\n const left = max < min ? (rtl ? max : min) : Math.min(Math.max(wanted, min), max);\n return { left, right: viewportWidth - left - panelWidth };\n}\n\n/**\n * The region actually on screen, tracked while `active` (Keksdose live #328).\n *\n * The same measurement {@link useAnchoredPanel} caps a dropdown with, for the callers\n * that need the box itself rather than a placement: a FULL-SCREEN sheet cannot use\n * `inset-0`, because on Android the on-screen keyboard shrinks only the VISUAL\n * viewport — `inset-0` still spans the whole screen, so the bottom of the sheet, and\n * the end of whatever list is scrolling inside it, live behind the keyboard where no\n * gesture reaches them.\n *\n * Returns `null` where there is nothing to correct: no `visualViewport` (jsdom, SSR,\n * older browsers) or no keyboard taking a bite out of it. A caller then keeps its\n * static layout, which is right at every width where this does not apply — and is why\n * the desktop and the test suite see exactly the markup they saw before.\n *\n * Also `null` while the page is pinch-zoomed (0.32, §10.2), with the guard\n * {@link readKeyboardInset} uses. A zoom shrinks the visual viewport like a keyboard,\n * and following it gave PickerSheet the zoomed region's top and height but the page's\n * full width — its close button sat off screen (kastlan, Kurvenschmiede). Zoomed, the\n * sheet keeps its static full-screen box, which the reader can pan to like the page.\n */\nexport function useVisualViewport(active: boolean): ViewportBox | null {\n const [box, setBox] = useState<ViewportBox | null>(null);\n\n useLayoutEffect(() => {\n if (!active) return;\n const update = () => {\n const vv = typeof window === \"undefined\" ? undefined : window.visualViewport;\n // The 1px slack is not superstition: `visualViewport.height` is fractional on a\n // device-pixel-ratio that is not an integer (his phone reports 1.25), so a\n // strict comparison would report a \"keyboard\" of 0.4px on every phone and pin\n // a height where none was needed.\n setBox(\n vv && !isZoomed(vv) && vv.height < window.innerHeight - 1 ? { top: vv.offsetTop, height: vv.height } : null,\n );\n };\n update();\n const vv = window.visualViewport;\n vv?.addEventListener(\"resize\", update);\n vv?.addEventListener(\"scroll\", update);\n window.addEventListener(\"resize\", update);\n return () => {\n vv?.removeEventListener(\"resize\", update);\n vv?.removeEventListener(\"scroll\", update);\n window.removeEventListener(\"resize\", update);\n };\n }, [active]);\n\n return box;\n}\n\n/**\n * Where to put a panel anchored under `ref`, tracking the visible viewport.\n *\n * Callers own horizontal placement, through {@link anchoredInline} — the pickers align\n * to the trigger's start edge, the popovers to its end — and must apply `maxHeight` with\n * an internal `overflow-y`.\n * Vertically, apply `top` while the panel is below and `bottom` (with no `top`) once it\n * is `above`; see {@link AnchoredPanel}.\n */\nexport function useAnchoredPanel<T extends HTMLElement>(\n ref: RefObject<T | null>,\n open: boolean,\n options: AnchoredPanelOptions = {},\n): AnchoredPanel {\n const rect = useAnchoredRect(ref, open);\n const [viewport, setViewport] = useState<ViewportBox>(readViewport);\n // A panel of rows wants room for the same number of rows at every text size; the gap\n // and the edge margin are not text and stay px.\n const { scale } = useTextSize();\n const preferredHeight = (options.preferredHeight ?? 320) * scale;\n const minHeight = (options.minHeight ?? 160) * scale;\n\n useLayoutEffect(() => {\n if (!open) return;\n const update = () => setViewport(readViewport());\n update();\n const vv = window.visualViewport;\n // The visualViewport events are the ones that fire when the keyboard opens;\n // window.resize is the fallback for browsers without the API (and for a genuine\n // window resize on desktop).\n vv?.addEventListener(\"resize\", update);\n vv?.addEventListener(\"scroll\", update);\n window.addEventListener(\"resize\", update);\n return () => {\n vv?.removeEventListener(\"resize\", update);\n vv?.removeEventListener(\"scroll\", update);\n window.removeEventListener(\"resize\", update);\n };\n }, [open]);\n\n if (!rect) {\n return { rect: null, top: 0, bottom: 0, maxHeight: preferredHeight, above: false };\n }\n return { rect, ...anchoredPanelPlacement(rect, viewport, { ...options, preferredHeight, minHeight }) };\n}\n"],"mappings":"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","names":["vv"]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-anchored-panel.ts"],"sourcesContent":["import { useLayoutEffect, useState } from \"react\";\nimport type { RefObject } from \"react\";\nimport { useAnchoredRect, type AnchorRect } from \"./use-anchored-rect\";\nimport { useTextSize } from \"../theme/text-size\";\nimport type { Direction } from \"../lib/direction\";\n\n/**\n * Vertical placement for a portalled, `position: fixed` panel anchored to a trigger.\n *\n * The problem it solves (feedback #135): on a phone, opening a picker focuses its\n * search box, the on-screen keyboard slides up, and a panel pinned below a trigger in\n * the lower half of the screen ends up entirely behind that keyboard — with nothing\n * the user can do about it, because `position: fixed` does not scroll.\n *\n * The keyboard is invisible to the usual measurements: on Android Chrome the LAYOUT\n * viewport (`window.innerHeight`, and the origin `getBoundingClientRect` and\n * `position: fixed` are relative to) does not shrink when it opens. Only\n * `visualViewport` reports the region actually on screen, so that is what this reads.\n */\n\n/** The on-screen region, in layout-viewport coordinates. */\nexport interface ViewportBox {\n top: number;\n height: number;\n /**\n * The height of the LAYOUT viewport itself, which `position: fixed`'s `bottom` is\n * measured from. Only {@link anchoredPanelPlacement} reads it, for a flipped panel's\n * `bottom`. Defaults to `top + height`: the two are the same box whenever no keyboard\n * and no zoom shrink the visible region.\n */\n layoutHeight?: number;\n}\n\n/**\n * Whether the page is pinch-zoomed (docs/text-size-harmonization.md §10.2). A zoom\n * shrinks `visualViewport.height` exactly like an on-screen keyboard does, so every\n * reader below must tell the two apart — and while zoomed, the answer is \"no keyboard\":\n * following a zoomed viewport would drag a pinned row on every pan, magnified, and the\n * browser already scrolls a focused field into view. The epsilon absorbs a scale of\n * 1.0000001 that some engines report at rest.\n */\nfunction isZoomed(vv: VisualViewport): boolean {\n return vv.scale > 1.001;\n}\n\n/** Below this the \"keyboard\" is rounding noise or a browser UI strip — `innerHeight` and\n * `visualViewport.height` routinely disagree by a pixel or two — and nothing should move\n * (keksdose's `MIN_KEYBOARD_PX`). */\nconst MIN_KEYBOARD_PX = 100;\n\nexport interface KeyboardInsetOptions {\n /** The smallest inset that counts as a keyboard. Default 100 px. */\n minimum?: number;\n}\n\n/**\n * How many CSS px at the bottom of the LAYOUT viewport the on-screen keyboard covers, now\n * — the one guarded reader (§10.2), so the apps' copies go (keksdose's\n * `use-keyboard-inset.ts`):\n *\n * - 0 while the page is pinch-zoomed (`visualViewport.scale > 1`), where a smaller visual\n * viewport is the zoom, not a keyboard;\n * - 0 below `minimum` (100 px), which is noise;\n * - 0 without `visualViewport` (SSR, jsdom, an old browser) and under\n * `interactive-widget=resizes-content`, where `bottom: 0` already lands above the\n * keyboard.\n *\n * Read it on `visualViewport`'s `resize` and `scroll` events (window `resize` as the\n * fallback); a bottom-anchored control lifts itself by the result.\n */\nexport function readKeyboardInset({ minimum = MIN_KEYBOARD_PX }: KeyboardInsetOptions = {}): number {\n if (typeof window === \"undefined\") return 0;\n const vv = window.visualViewport;\n if (!vv || isZoomed(vv)) return 0;\n const inset = Math.round(window.innerHeight - vv.height - vv.offsetTop);\n return inset >= minimum ? inset : 0;\n}\n\nconst UNMEASURED_VIEWPORT: ViewportBox = { top: 0, height: 0 };\n\nfunction readViewport(): ViewportBox {\n if (typeof window === \"undefined\") return { top: 0, height: 0 };\n const vv = window.visualViewport;\n // The layout viewport's height is `innerHeight`, the reference `readKeyboardInset`\n // measures the keyboard against. The keyboard leaves it alone (it shrinks only the\n // VISUAL viewport; under `interactive-widget=resizes-content` it shrinks both, the\n // fixed box with them), and it follows the mobile URL bar as it slides away, as a\n // fixed box's `bottom` does. Not `documentElement.clientHeight`: on mobile Chrome and\n // Safari that stays at the small, URL-bar-shown height, so with the bar scrolled away\n // a flipped panel would sit that much too low, over its own field. `innerHeight`'s one\n // error is a desktop page with a horizontal scrollbar, which it counts and the fixed\n // box does not: the gap widens by the bar's thickness, and never closes.\n const layoutHeight = window.innerHeight;\n // offsetTop, not 0: with the keyboard up the user can also pan the visual viewport\n // within the layout viewport, which shifts where \"visible\" starts.\n return vv\n ? { top: vv.offsetTop, height: vv.height, layoutHeight }\n : { top: 0, height: window.innerHeight, layoutHeight };\n}\n\nexport interface AnchoredPanelOptions {\n /** Space between trigger and panel. */\n gap?: number;\n /** Space kept clear at the viewport edges. */\n margin?: number;\n /** The height the panel would like, when there is room. In px at Normal text size:\n * {@link useAnchoredPanel} multiplies it by the size's scale, since what a panel\n * holds is rows of text (0.32, docs/text-size-harmonization.md §3.2). */\n preferredHeight?: number;\n /** Below this, the space under the trigger counts as unusable and a flip is considered.\n * In px at Normal, scaled likewise. */\n minHeight?: number;\n}\n\n/**\n * `top` and `bottom` are the two edges of a panel exactly `maxHeight` tall. A panel is\n * sized by its content and `maxHeight` only caps it, so apply the edge on the trigger's\n * side, the one that holds whatever the height: `top` while the panel is below,\n * `bottom` (and no `top`) once it is flipped `above`.\n */\nexport interface AnchoredPanel {\n /** The trigger's rect, or null while closed/unmeasured. */\n rect: AnchorRect | null;\n /** Viewport-space `top` for the panel — the one to apply while it is below. Flipped,\n * it is where a FULL-height panel's top would be, and a three-row list placed by it\n * floated far above its field (keksdose K1, live #401); place that one by `bottom`. */\n top: number;\n /** CSS `bottom` for a `position: fixed` panel: the distance from the layout\n * viewport's bottom edge up to the panel's. The one to apply once it is flipped\n * `above`: it keeps the panel's foot `gap` above the trigger however short its\n * content, and while a filtered list shrinks or grows under the user's typing. */\n bottom: number;\n /** Cap that keeps the panel inside the VISIBLE viewport — apply as `maxHeight`. */\n maxHeight: number;\n /** Whether the panel was flipped above the trigger. */\n above: boolean;\n}\n\n/** Absolute floor: below this a list is useless, so overflow the margin instead. */\nconst FLOOR = 96;\n\n/** How much more room above must offer, when neither side fits the preferred height,\n * before the panel changes sides — so it never jumps for a few pixels' gain. */\nconst FLIP_GAIN = 48;\n\n/**\n * The pure half of {@link useAnchoredPanel}: where a panel goes, given the trigger's\n * rect and the region actually on screen.\n *\n * Prefers below the trigger — the conventional direction, and the one that keeps the\n * trigger's own value visible — whenever the panel's preferred height fits there. When it\n * does not, the panel opens above if the full height fits there, or if above is clearly\n * roomier (by {@link FLIP_GAIN}); below an unusable `minHeight` it flips to any roomier\n * side, as before. It used to flip only below `minHeight`, so a select with 160–319 px\n * under it opened downward as a cramped list with 600 px free above (keksdose live #384,\n * the currency select of the new-budget form near the bottom of the screen). The\n * returned `maxHeight` is what makes the flip sufficient rather than merely different:\n * without it a tall panel placed above just runs off the top instead of the bottom.\n *\n * A flipped panel hangs from its BOTTOM edge, `gap` above the trigger, and is placed by\n * `bottom` (keksdose K1, live #401). It used to be placed by a `top` worked out as\n * though the panel were `maxHeight` tall, and a panel is only as tall as its content:\n * a three-row address list under a 256 px cap sat ~176 px above its field, on a phone\n * with the keyboard up. Only when even the {@link FLOOR} does not fit above does that\n * edge give way, as `top` always did, so the panel's head (a search box, often) stays\n * on screen rather than above it.\n *\n * Split out so the geometry is testable without a DOM and a fake keyboard — @hb/ui has\n * no test runner of its own, so this is exercised from the consuming app.\n */\nexport function anchoredPanelPlacement(\n rect: Pick<AnchorRect, \"top\" | \"bottom\">,\n viewport: ViewportBox,\n { gap = 4, margin = 8, preferredHeight = 320, minHeight = 160 }: AnchoredPanelOptions = {},\n): Omit<AnchoredPanel, \"rect\"> {\n const viewTop = viewport.top + margin;\n const viewBottom = viewport.top + viewport.height - margin;\n const spaceBelow = viewBottom - (rect.bottom + gap);\n const spaceAbove = rect.top - gap - viewTop;\n const above =\n spaceAbove > spaceBelow &&\n (spaceBelow < minHeight ||\n (spaceBelow < preferredHeight && (spaceAbove >= preferredHeight || spaceAbove - spaceBelow >= FLIP_GAIN)));\n const space = Math.max(FLOOR, above ? spaceAbove : spaceBelow);\n const maxHeight = Math.min(preferredHeight, space);\n // Unchanged, for the apps that call the hook and apply it: the edge to use below, and\n // a full-height panel's top above. In layout-viewport space, like the rect.\n const top = above ? Math.max(viewTop, rect.top - gap - maxHeight) : rect.bottom + gap;\n // `bottom` counts up from the LAYOUT viewport's bottom edge, not the visible region's:\n // `position: fixed` is laid out in the layout viewport, which runs on behind the\n // keyboard, exactly as the rect is measured in it. The keyboard, the pan and the zoom\n // are already in `maxHeight`, which keeps the panel's top inside what is on screen.\n const layoutHeight = viewport.layoutHeight ?? viewport.top + viewport.height;\n return { top, bottom: layoutHeight - (top + maxHeight), maxHeight, above };\n}\n\n/**\n * The glass a panel has to stay on, in CSS pixels — `window.innerWidth` is the wrong\n * width on a phone (keksdose run 72, live #381). There it is the LAYOUT viewport, and\n * that grows to the page's width as soon as anything sticks out past the screen: 430 on\n * a 406px screen in Chromium's mobile emulation, with `position: fixed` layers laid out\n * against the 430. A panel that had widened the page then measured itself as inside it.\n * The root element's client width is the initial containing block instead — the screen,\n * the width `100vw` and the media queries see — whatever overflows, and on a desktop it\n * also leaves a classic scrollbar out, which is where a fixed box's `right` is measured\n * from. It is 0 where nothing is laid out (jsdom), and the window is the answer there.\n * The height stays the window's: nothing measured grows it, and a phone's collapsing\n * toolbar makes the window the truer of the two.\n *\n * Tooltip's lesson, moved here so every anchored panel reads the same width (round 0.34\n * Wave 2, DUP-7).\n */\nexport function viewportSize(): { width: number; height: number } {\n return {\n width: document.documentElement.clientWidth || window.innerWidth,\n height: window.innerHeight,\n };\n}\n\nexport interface AnchoredInlineOptions {\n /** The trigger's reading direction, which `start`, `end` and `after` follow. Default\n * `ltr`. A portalled panel has left the subtree it would inherit `dir` from, so read\n * it off the trigger (`dirOf`). */\n dir?: Direction;\n /**\n * Which of the panel's edges lines up with which of the trigger's:\n * - `start` / `end` — the panel's start (end) edge on the trigger's start (end) edge,\n * by reading direction: a picker hangs from its field's start, a popover from its\n * button's end;\n * - `left` / `right` — the same, physically;\n * - `after` — the panel's start edge on the trigger's END edge: beside it, as the\n * sidebar's flyout opens.\n *\n * Default `start`.\n */\n align?: \"start\" | \"end\" | \"left\" | \"right\" | \"after\";\n /** The room kept from either edge of the glass, in px. Default 8. */\n edge?: number;\n /** The glass's width. Default {@link viewportSize}'s. */\n viewportWidth?: number;\n}\n\n/** The horizontal place of a `position: fixed` panel: its `left`, and the `right` of the\n * same box for a panel hung from its right edge (both in px, from the glass's edges). */\nexport interface InlinePlacement {\n left: number;\n right: number;\n}\n\n/**\n * The horizontal half of an anchored panel, beside {@link anchoredPanelPlacement}'s\n * vertical one (round 0.34 Wave 2, DUP-7): where a panel `panelWidth` wide goes against\n * the trigger's `rect`, kept `edge` px inside the glass ({@link viewportSize}).\n *\n * Popover, DropdownPanel, ComboboxPanel, HoverMenu and the AppShell flyout each wrote\n * this themselves, against three different widths: `innerWidth`, which on a phone is\n * the grown layout viewport and on a desktop counts the scrollbar; the root's client\n * width; and, for the entity picker's panel in a left-to-right form, no clamp at all.\n *\n * A panel wider than the room keeps its START on the glass — its left edge in a\n * left-to-right form, its right in a right-to-left one — as Tooltip's slide does: the\n * start of a row of text is the half worth keeping.\n */\nexport function anchoredInline(\n rect: Pick<AnchorRect, \"left\" | \"right\">,\n panelWidth: number,\n { dir = \"ltr\", align = \"start\", edge = 8, viewportWidth = viewportSize().width }: AnchoredInlineOptions = {},\n): InlinePlacement {\n const rtl = dir === \"rtl\";\n // Where the panel's LEFT edge goes, before the clamp.\n let wanted: number;\n switch (align) {\n case \"left\":\n wanted = rect.left;\n break;\n case \"right\":\n wanted = rect.right - panelWidth;\n break;\n case \"start\":\n wanted = rtl ? rect.right - panelWidth : rect.left;\n break;\n case \"end\":\n wanted = rtl ? rect.left : rect.right - panelWidth;\n break;\n case \"after\":\n wanted = rtl ? rect.left - panelWidth : rect.right;\n break;\n }\n const min = edge;\n const max = viewportWidth - edge - panelWidth;\n const left = max < min ? (rtl ? max : min) : Math.min(Math.max(wanted, min), max);\n return { left, right: viewportWidth - left - panelWidth };\n}\n\n/**\n * The region actually on screen, tracked while `active` (Keksdose live #328).\n *\n * The same measurement {@link useAnchoredPanel} caps a dropdown with, for the callers\n * that need the box itself rather than a placement: a FULL-SCREEN sheet cannot use\n * `inset-0`, because on Android the on-screen keyboard shrinks only the VISUAL\n * viewport — `inset-0` still spans the whole screen, so the bottom of the sheet, and\n * the end of whatever list is scrolling inside it, live behind the keyboard where no\n * gesture reaches them.\n *\n * Returns `null` where there is nothing to correct: no `visualViewport` (jsdom, SSR,\n * older browsers) or no keyboard taking a bite out of it. A caller then keeps its\n * static layout, which is right at every width where this does not apply — and is why\n * the desktop and the test suite see exactly the markup they saw before.\n *\n * Also `null` while the page is pinch-zoomed (0.32, §10.2), with the guard\n * {@link readKeyboardInset} uses. A zoom shrinks the visual viewport like a keyboard,\n * and following it gave PickerSheet the zoomed region's top and height but the page's\n * full width — its close button sat off screen (kastlan, Kurvenschmiede). Zoomed, the\n * sheet keeps its static full-screen box, which the reader can pan to like the page.\n */\nexport function useVisualViewport(active: boolean): ViewportBox | null {\n const [box, setBox] = useState<ViewportBox | null>(null);\n\n useLayoutEffect(() => {\n if (!active) return;\n const update = () => {\n const vv = typeof window === \"undefined\" ? undefined : window.visualViewport;\n // The 1px slack is not superstition: `visualViewport.height` is fractional on a\n // device-pixel-ratio that is not an integer (his phone reports 1.25), so a\n // strict comparison would report a \"keyboard\" of 0.4px on every phone and pin\n // a height where none was needed.\n setBox(\n vv && !isZoomed(vv) && vv.height < window.innerHeight - 1 ? { top: vv.offsetTop, height: vv.height } : null,\n );\n };\n update();\n const vv = window.visualViewport;\n vv?.addEventListener(\"resize\", update);\n vv?.addEventListener(\"scroll\", update);\n window.addEventListener(\"resize\", update);\n return () => {\n vv?.removeEventListener(\"resize\", update);\n vv?.removeEventListener(\"scroll\", update);\n window.removeEventListener(\"resize\", update);\n };\n }, [active]);\n\n return box;\n}\n\n/**\n * Where to put a panel anchored under `ref`, tracking the visible viewport.\n *\n * Callers own horizontal placement, through {@link anchoredInline} — the pickers align\n * to the trigger's start edge, the popovers to its end — and must apply `maxHeight` with\n * an internal `overflow-y`.\n * Vertically, apply `top` while the panel is below and `bottom` (with no `top`) once it\n * is `above`; see {@link AnchoredPanel}.\n */\nexport function useAnchoredPanel<T extends HTMLElement>(\n ref: RefObject<T | null>,\n open: boolean,\n options: AnchoredPanelOptions = {},\n): AnchoredPanel {\n const rect = useAnchoredRect(ref, open);\n // Not read while rendering (0.48.2, keksdose K-052-2): in Chromium `visualViewport`'s\n // geometry forces a style and layout pass on a dirty DOM, and every anchored panel —\n // closed ones too — paid it on mount (a bulk bar's pickers appearing on the first\n // ticked row of a long table paid a layout of the whole table). Nothing reads this\n // before the layout effect below has filled it: `rect` is null until the panel is open\n // and measured, and that same effect pass measures the viewport.\n const [viewport, setViewport] = useState<ViewportBox>(UNMEASURED_VIEWPORT);\n // A panel of rows wants room for the same number of rows at every text size; the gap\n // and the edge margin are not text and stay px.\n const { scale } = useTextSize();\n const preferredHeight = (options.preferredHeight ?? 320) * scale;\n const minHeight = (options.minHeight ?? 160) * scale;\n\n useLayoutEffect(() => {\n if (!open) return;\n const update = () => setViewport(readViewport());\n update();\n const vv = window.visualViewport;\n // The visualViewport events are the ones that fire when the keyboard opens;\n // window.resize is the fallback for browsers without the API (and for a genuine\n // window resize on desktop).\n vv?.addEventListener(\"resize\", update);\n vv?.addEventListener(\"scroll\", update);\n window.addEventListener(\"resize\", update);\n return () => {\n vv?.removeEventListener(\"resize\", update);\n vv?.removeEventListener(\"scroll\", update);\n window.removeEventListener(\"resize\", update);\n };\n }, [open]);\n\n if (!rect) {\n return { rect: null, top: 0, bottom: 0, maxHeight: preferredHeight, above: false };\n }\n return { rect, ...anchoredPanelPlacement(rect, viewport, { ...options, preferredHeight, minHeight }) };\n}\n"],"mappings":"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","names":["vv"]}
|
package/dist/shell/app-shell.js
CHANGED
|
@@ -195,7 +195,13 @@ function AppShell({
|
|
|
195
195
|
// into the rest from a zero basis, and a window too short for the page's
|
|
196
196
|
// floor scrolls `<main>`. Every other page lays out as before. Below `md`
|
|
197
197
|
// the document scrolls and nothing fills.
|
|
198
|
-
|
|
198
|
+
// The children's `shrink-0` carries no `:has`: `flex-shrink` does nothing
|
|
199
|
+
// outside a flex container, and a `:has(…) > *` rule cost a style recalc of
|
|
200
|
+
// the whole page on every DOM insertion under it (17–45 ms per scrolled step
|
|
201
|
+
// of a `virtualize` table, 0.48.2). `[&&>*]` doubles the class, so it stays
|
|
202
|
+
// as specific as FillHeight's `has-[…]:flex-1` (whose `flex: 1` would set
|
|
203
|
+
// shrink 1) and, emitted after it, still wins.
|
|
204
|
+
"md:has-[[data-fill-height]]:flex md:has-[[data-fill-height]]:flex-col md:[&&>*]:shrink-0",
|
|
199
205
|
// Embedded there is no document scroll to lean on below `md` either.
|
|
200
206
|
embedded && "min-h-0 overflow-y-auto"
|
|
201
207
|
),
|