@astryxdesign/cli 0.5.2-canary.e4f8e4e → 0.5.2-canary.ed45a8f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/api/theme/targets/targets.test.mjs +15 -0
  2. package/assets/docs/icons.doc.mjs +2 -2
  3. package/assets/docs/typography.doc.mjs +2 -2
  4. package/assets/templates/blocks/components/DateTimeInput/DateTimeInputShowcase.tsx +4 -1
  5. package/assets/templates/blocks/components/Table/TableRowStatusTable.doc.mjs +3 -3
  6. package/assets/templates/blocks/components/Table/TableRowStatusTable.tsx +4 -4
  7. package/assets/templates/blocks/components/TimeInput/TimeInputShowcase.doc.mjs +1 -1
  8. package/assets/templates/blocks/components/TimeInput/TimeInputShowcase.tsx +12 -6
  9. package/assets/templates/pages/ai-chat/template.doc.mjs +1 -1
  10. package/assets/templates/pages/centered-hero/template.doc.mjs +1 -1
  11. package/assets/templates/pages/contact-form/template.doc.mjs +1 -1
  12. package/assets/templates/pages/dashboard/template.doc.mjs +1 -1
  13. package/assets/templates/pages/detail-page/template.doc.mjs +1 -1
  14. package/assets/templates/pages/gallery-hero/template.doc.mjs +1 -1
  15. package/assets/templates/pages/incident-console/template.doc.mjs +1 -1
  16. package/assets/templates/pages/login/template.doc.mjs +1 -1
  17. package/assets/templates/pages/messaging-shell/template.doc.mjs +1 -1
  18. package/assets/templates/pages/product-gallery/template.doc.mjs +1 -1
  19. package/assets/templates/pages/settings/template.doc.mjs +1 -1
  20. package/assets/templates/pages/table/template.doc.mjs +1 -1
  21. package/assets/templates/pages/table-filter/page.tsx +897 -365
  22. package/foundation/discovery/__fixtures__/component-accessibility-overlay.doc.d.mts +16 -1
  23. package/foundation/discovery/__fixtures__/component-accessibility-overlay.doc.mjs +17 -2
  24. package/foundation/discovery/__fixtures__/component-anatomy-override.doc.d.mts +38 -0
  25. package/foundation/discovery/__fixtures__/component-anatomy-override.doc.mjs +45 -0
  26. package/foundation/discovery/component-loader.mjs +6 -2
  27. package/foundation/discovery/component-loader.test.mjs +36 -1
  28. package/foundation/discovery/componentDocOverlay.test.mjs +29 -2
  29. package/foundation/discovery/theming-targets.d.mts +8 -3
  30. package/foundation/discovery/theming-targets.mjs +58 -6
  31. package/foundation/discovery/theming-targets.test.mjs +71 -2
  32. package/package.json +9 -9
@@ -31,6 +31,7 @@ import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
31
31
  import type {
32
32
  KeyboardEvent as ReactKeyboardEvent,
33
33
  PointerEvent as ReactPointerEvent,
34
+ ReactNode,
34
35
  SVGProps,
35
36
  } from 'react';
36
37
  import * as stylex from '@stylexjs/stylex';
@@ -56,7 +57,11 @@ import {
56
57
  import {Item} from '@astryxdesign/core/Item';
57
58
  import {List} from '@astryxdesign/core/List';
58
59
  import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
59
- import {useAnnounce} from '@astryxdesign/core/hooks';
60
+ import {useAnnounce, useMediaQuery} from '@astryxdesign/core/hooks';
61
+ import {BottomSheet} from '@astryxdesign/core/BottomSheet';
62
+ import {OverflowList} from '@astryxdesign/core/OverflowList';
63
+ import type {OverflowItem} from '@astryxdesign/core/OverflowList';
64
+ import {Tab, TabList} from '@astryxdesign/core/TabList';
60
65
  import {MetadataList, MetadataListItem} from '@astryxdesign/core/MetadataList';
61
66
  import {Heading, Text} from '@astryxdesign/core/Text';
62
67
  import {Button} from '@astryxdesign/core/Button';
@@ -1107,6 +1112,53 @@ const INITIAL_SAVED_VIEWS: SavedView[] = [
1107
1112
  // Helpers
1108
1113
  // =============================================================================
1109
1114
 
1115
+ /**
1116
+ * The surfaces below swap to a bottom sheet on a compact surface.
1117
+ *
1118
+ * Two terms, because there are two ways to be one. The second is the query the
1119
+ * Dialog adaptive-presentation block uses — up to `lg` on a device whose
1120
+ * pointer is a finger — and it is what catches a tablet that has the width for
1121
+ * a popover but no hover to drive one.
1122
+ *
1123
+ * The first is width alone, at any input. A 660px popover and a resizable side
1124
+ * panel need room the window no longer has well before the pointer changes:
1125
+ * below `md` the popover has nowhere to sit, and the panel leaves the table a
1126
+ * couple of columns wide. A mouse does not rescue a layout that does not fit,
1127
+ * so the narrow window gets sheets too.
1128
+ */
1129
+ const COMPACT_SURFACE_QUERY =
1130
+ '(max-width: 768px), (max-width: 1024px) and (pointer: coarse) and (hover: none)';
1131
+
1132
+ /**
1133
+ * Top padding for whatever opens a BottomSheet, on the spacing scale.
1134
+ *
1135
+ * The panel's grab pill floats over the content instead of reserving layout
1136
+ * space for itself: it is 4px centered in a 24px band, so it covers the top
1137
+ * 10-14px of the sheet. Content that starts at the usual 16px gutter clears it
1138
+ * by 2px, which does not read as a gap — it reads as the sheet being cut off.
1139
+ * Opening at the full 24px band puts real space under the pill, and every
1140
+ * sheet here uses this one value so they all start on the same line.
1141
+ */
1142
+ const SHEET_TOP_INSET = 6;
1143
+
1144
+ /**
1145
+ * Width the filter row holds back, in px, for the readout that trails the
1146
+ * clauses — the density toggle, the result count and Clear all.
1147
+ *
1148
+ * The readout sits inside the slot the overflow list measures, so without this
1149
+ * the list would count the readout's width as room for another clause and
1150
+ * push it off the end. The slot pays for it as padding, which is the one part
1151
+ * of the slot the measurement already subtracts, and the readout takes the
1152
+ * width back with a matching negative margin so it still lands against the
1153
+ * last clause instead of at the far edge.
1154
+ *
1155
+ * Deliberately a little wider than the readout draws at the counts this page
1156
+ * reaches, because the two failure directions are not symmetric: too wide only
1157
+ * folds a clause marginally early, while too narrow puts the readout over the
1158
+ * edge of the row.
1159
+ */
1160
+ const READOUT_RESERVE = 184;
1161
+
1110
1162
  /**
1111
1163
  * Stands in for Relay's `usePaginationFragment` so the table can grow by
1112
1164
  * batches instead of flipping pages. It returns the same
@@ -1272,11 +1324,58 @@ const styles = stylex.create({
1272
1324
  bar: {
1273
1325
  rowGap: 6,
1274
1326
  },
1275
- // Wraps rather than collapsing (see the note in filterBar): clauses that no
1276
- // longer fit move to a second line, keeping the count beside the last one.
1327
+ // Collapses rather than wrapping: clauses that no longer fit fold into the
1328
+ // overflow trigger, so the row is one line at every width.
1277
1329
  filterRow: {
1278
1330
  rowGap: 6,
1279
1331
  },
1332
+ // This slot is what `observeParent` measures, so its width has to mean "the
1333
+ // room the clauses have" and nothing else. Grow is the part that makes that
1334
+ // true: as the row's only grower it settles at the width left over once the
1335
+ // search box has taken its share, whatever the clauses inside happen to add
1336
+ // up to.
1337
+ //
1338
+ // Left at grow 0 the slot is flex-basis auto, which is to say content-sized,
1339
+ // and that feeds the list its own output: collapse one clause, the content
1340
+ // narrows, the slot narrows with it, the list re-reads the smaller number
1341
+ // and collapses again. It runs all the way down to `minVisibleItems` in one
1342
+ // pass. At 1200px that showed as a single clause and a +6 next to 500px of
1343
+ // empty row. Grow breaks the circuit by sizing the slot from the row instead
1344
+ // of from the clauses.
1345
+ //
1346
+ // The slot is also the readout's container rather than its sibling, which is
1347
+ // what keeps the readout beside the last clause instead of riding the row's
1348
+ // trailing edge — see READOUT_RESERVE for how its width is kept out of the
1349
+ // measurement.
1350
+ filterOverflowSlot: {
1351
+ display: 'flex',
1352
+ alignItems: 'center',
1353
+ flexGrow: 1,
1354
+ // The held-back width, as padding because padding is the one part of the
1355
+ // box the measurement already subtracts.
1356
+ paddingInlineEnd: READOUT_RESERVE,
1357
+ // Weighted far above the search box's 1 so the row gives in an order
1358
+ // rather than proportionally. Flex shares a deficit by shrink factor times
1359
+ // basis, so a 100 here means the clauses fold away to the bare count
1360
+ // before the field gives up its first pixel, and the field only starts
1361
+ // shrinking once there is nothing left to fold.
1362
+ flexShrink: 100,
1363
+ // Where folding stops. StackItem resets min-width to 0, and with a shrink
1364
+ // this aggressive that lets the slot pass the collapsed state and clip the
1365
+ // count itself — the filters are then set, applied, and unreachable, with
1366
+ // nothing on the row to say so. The floor is the trigger's own 44px plus
1367
+ // the reserve, because min-width bounds the border box and the reserve is
1368
+ // padding inside it — 44 on its own would floor a slot with nothing left
1369
+ // to put the trigger in. The last thing the row gives up is the way back
1370
+ // to what it folded.
1371
+ minWidth: 44 + READOUT_RESERVE,
1372
+ },
1373
+ // See the note in FilterOverflow: a flex column stretches its child, which
1374
+ // is what puts the button-backed presets on the same edge as the selectors.
1375
+ overflowItem: {
1376
+ display: 'flex',
1377
+ flexDirection: 'column',
1378
+ },
1280
1379
  // Queried rather than @media: opening the detail panel narrows this header
1281
1380
  // without the viewport changing, and that is the width the toolbar actually
1282
1381
  // has to fit in.
@@ -1299,8 +1398,45 @@ const styles = stylex.create({
1299
1398
  toolbarEnd: {
1300
1399
  marginInlineStart: 'auto',
1301
1400
  },
1401
+ // Takes back the width the slot held back for it, so the readout draws over
1402
+ // the reserve rather than after it and lands against the last clause. The
1403
+ // gap to that clause is a margin rather than the slot's `gap`, because a gap
1404
+ // would fall on the measured side of the reserve and cost the clauses the
1405
+ // last few pixels they were measured to have.
1302
1406
  filterMeta: {
1303
1407
  flexShrink: 0,
1408
+ marginInlineStart: 8,
1409
+ marginInlineEnd: -READOUT_RESERVE,
1410
+ },
1411
+ savedViewsLabel: {
1412
+ flexShrink: 0,
1413
+ },
1414
+ // Basis auto so the slot is exactly as wide as the chips while they fit —
1415
+ // shrinking only once they do not, which is the width the scroller inside
1416
+ // then has to work with.
1417
+ savedViewsSlot: {
1418
+ flexShrink: 1,
1419
+ minWidth: 0,
1420
+ },
1421
+ savedViewsScroller: {
1422
+ overflowX: 'auto',
1423
+ // The row is one line: without this the block axis picks up a scrollbar of
1424
+ // its own the moment the inline one appears.
1425
+ overflowY: 'hidden',
1426
+ // Keeps a swipe that runs off the end of the chips from turning into a
1427
+ // page scroll, which on a phone reads as the whole view lurching sideways.
1428
+ overscrollBehaviorInline: 'contain',
1429
+ // No scrollbar under a 32px row: it would eat as much height as the chips
1430
+ // and sit there permanently on the platforms that reserve space for it.
1431
+ // The chip cut off at the edge is the affordance instead, and every chip
1432
+ // is a button, so tabbing scrolls the next one into view for a keyboard.
1433
+ scrollbarWidth: 'none',
1434
+ },
1435
+ // In a nowrap row that scrolls, a shrinkable child squeezes to fit rather
1436
+ // than overflowing: without this the chips compress into unreadable stubs
1437
+ // and the scroller never has anything to scroll.
1438
+ savedViewChip: {
1439
+ flexShrink: 0,
1304
1440
  },
1305
1441
  // A saved view can carry more clauses than the dialog is wide.
1306
1442
  tokenWrap: {
@@ -1347,12 +1483,31 @@ const styles = stylex.create({
1347
1483
  },
1348
1484
  },
1349
1485
  // Sits at the width of the filter selectors beside it so the row reads as
1350
- // one family of controls, and widens on focus for typing room. `xstyle`
1351
- // lands on the input's own wrapper, so `:focus-within` is already in scope
1352
- // and no React state is needed. TextInput's `width` prop is the sanctioned
1353
- // way to size a field, but it takes one static value and cannot express a
1354
- // focus variant, so the width lives here instead.
1355
- searchInput: {
1486
+ // one family of controls, and widens on focus for typing room. TextInput's
1487
+ // `width` prop is the sanctioned way to size a field, but it takes one
1488
+ // static value and cannot express a focus variant, so the width lives here.
1489
+ //
1490
+ // On the slot rather than on the field: TextInput's `xstyle` lands on an
1491
+ // element inside its outer wrapper, and that wrapper is the row's actual
1492
+ // flex item. Sized from within, the wrapper keeps `min-width: auto`, its
1493
+ // min-content becomes the focused 240px, and no shrink factor can move it —
1494
+ // the field stays rigid and pushes the readout off the end of the row. A
1495
+ // StackItem resets that min-width, so the width and the give belong to the
1496
+ // same box and the field can widen into space it actually has.
1497
+ //
1498
+ // `:focus-within` is in scope here for the same reason it was on the field:
1499
+ // the focused input is a descendant either way, so no React state is needed.
1500
+ searchSlot: {
1501
+ // Second in line to give, behind the overflow slot's 100 and ahead of the
1502
+ // readout's 0 (see filterOverflowSlot). The focus width is a request
1503
+ // rather than a reservation: a 393px row cannot seat 240px of search and
1504
+ // still show "Clear all", so the field takes what the folded clauses left
1505
+ // and stops there — full width where the window has it, a little under it
1506
+ // where it does not.
1507
+ flexShrink: 1,
1508
+ // Never narrower than its resting width, so a tight row cannot grind the
1509
+ // field down to a stub. Costs nothing idle: 120 is where it already sits.
1510
+ minWidth: 120,
1356
1511
  width: {
1357
1512
  default: 120,
1358
1513
  ':focus-within': 240,
@@ -1454,6 +1609,18 @@ const styles = stylex.create({
1454
1609
  width: '100%',
1455
1610
  justifyContent: 'flex-start',
1456
1611
  },
1612
+ // Starts the strip on the sheet's own 16px gutter instead of the container
1613
+ // edge, so the first tab lines up with the panel below it. Padding rather
1614
+ // than margin: on a scroller the gutter has to be part of the scrollable box
1615
+ // or the strip cannot scroll its last tab clear of the trailing edge.
1616
+ //
1617
+ // Inline only. The sheet's top inset is the wrapper's, not this box's: the
1618
+ // strip's scroll arrow is absolutely positioned against this box at
1619
+ // inset-block-start 0, so block padding here pushes the tabs down and leaves
1620
+ // the arrow hanging above the row it scrolls.
1621
+ viewSheetTabs: {
1622
+ paddingInline: 16,
1623
+ },
1457
1624
  // Full height so the transfer panels can reach the popover floor: sized to
1458
1625
  // its content instead, the pane stops short and the rule between the panels
1459
1626
  // ends in mid-air — most visibly when a search empties both sides.
@@ -1850,11 +2017,191 @@ function ViewSummaryList({
1850
2017
  );
1851
2018
  }
1852
2019
 
2020
+ /**
2021
+ * The clauses the row could not fit, behind one trigger.
2022
+ *
2023
+ * A DropdownMenu is the usual overflow anchor and is the wrong one here: these
2024
+ * items are selectors that open popovers of their own, and a popover opened
2025
+ * inside a menu fights the menu for the same dismissal. A plain panel holds
2026
+ * them as they already are, so a folded clause is the same control it was on
2027
+ * the row rather than a menu entry that re-states it.
2028
+ *
2029
+ * `child` is rendered straight through rather than rebuilt from `index`: the
2030
+ * element in an OverflowItem is the one the list measured, already bound to
2031
+ * the same filter state, so a clause set here and a clause set on the row are
2032
+ * the same control writing to the same place.
2033
+ */
2034
+ function FilterOverflow({
2035
+ items,
2036
+ isCompactSurface,
2037
+ }: {
2038
+ items: OverflowItem[];
2039
+ isCompactSurface: boolean;
2040
+ }) {
2041
+ const [isOpen, setIsOpen] = useState(false);
2042
+ const count = items.length;
2043
+ const label = `${count} more ${count === 1 ? 'filter' : 'filters'}`;
2044
+
2045
+ // Each control fills the panel rather than sizing to its own label the way
2046
+ // it does on the row: a column of content-width triggers reads as a ragged
2047
+ // edge, not a list. The stack stretches its items, but that only reaches the
2048
+ // wrapper — the selectors are block-level and follow it, while the preset
2049
+ // toggles are buttons and stay at their label width. Making each wrapper a
2050
+ // column stretches whatever is inside it, so both kinds land on one edge.
2051
+ const body = (
2052
+ <VStack
2053
+ gap={2}
2054
+ padding={isCompactSurface ? 4 : 0}
2055
+ paddingBlockStart={isCompactSurface ? SHEET_TOP_INSET : 0}>
2056
+ {items.map(({child, index}) => (
2057
+ <StackItem key={index} xstyle={styles.overflowItem}>
2058
+ {child}
2059
+ </StackItem>
2060
+ ))}
2061
+ </VStack>
2062
+ );
2063
+
2064
+ const trigger = (
2065
+ <Button
2066
+ label={`+${count}`}
2067
+ tooltip={label}
2068
+ aria-label={label}
2069
+ variant="ghost"
2070
+ size="sm"
2071
+ xstyle={[styles.filterChrome, styles.filterSurface]}
2072
+ onClick={isCompactSurface ? () => setIsOpen(true) : undefined}
2073
+ />
2074
+ );
2075
+
2076
+ if (isCompactSurface) {
2077
+ return (
2078
+ <>
2079
+ {trigger}
2080
+ {/* `hug` rather than a fixed budget: the folded set is however many
2081
+ clauses did not fit, so the sheet is as tall as it needs to be
2082
+ instead of holding a gap under three controls. */}
2083
+ <BottomSheet
2084
+ isOpen={isOpen}
2085
+ onOpenChange={setIsOpen}
2086
+ label={label}
2087
+ height="hug">
2088
+ {body}
2089
+ </BottomSheet>
2090
+ </>
2091
+ );
2092
+ }
2093
+
2094
+ return (
2095
+ <Popover
2096
+ placement="below"
2097
+ alignment="end"
2098
+ width={280}
2099
+ label={label}
2100
+ content={body}>
2101
+ {trigger}
2102
+ </Popover>
2103
+ );
2104
+ }
2105
+
2106
+ /**
2107
+ * The saved-view forms, as a dialog with a pointer and a sheet on a phone.
2108
+ *
2109
+ * Both hosts get `purpose="form"`, which is the same contract in each: the
2110
+ * scrim will not dismiss, and neither will a swipe on the sheet, so a name
2111
+ * half-typed survives a stray touch. That leaves Escape as the only implicit
2112
+ * way out — and a phone has no Escape — so the explicit close is not optional
2113
+ * here the way it is on a Dialog that can fall back to the keyboard. The X
2114
+ * below is the sheet's, matching the one DialogHeader supplies on the other
2115
+ * branch.
2116
+ *
2117
+ * `tall` for the same reason both forms take a name: only a fully expanded
2118
+ * tall sheet moves a focused field clear of the mobile keyboard, and a shorter
2119
+ * budget would leave the caret behind it.
2120
+ */
2121
+ function AdaptiveSavedViewForm({
2122
+ isOpen,
2123
+ onOpenChange,
2124
+ title,
2125
+ subtitle,
2126
+ isCompactSurface,
2127
+ children,
2128
+ }: {
2129
+ isOpen: boolean;
2130
+ onOpenChange: (isOpen: boolean) => void;
2131
+ title: string;
2132
+ subtitle: string;
2133
+ isCompactSurface: boolean;
2134
+ children: ReactNode;
2135
+ }) {
2136
+ if (isCompactSurface) {
2137
+ return (
2138
+ <BottomSheet
2139
+ isOpen={isOpen}
2140
+ onOpenChange={onOpenChange}
2141
+ label={title}
2142
+ purpose="form"
2143
+ height="tall">
2144
+ <Section
2145
+ variant="transparent"
2146
+ padding={4}
2147
+ paddingBlockStart={SHEET_TOP_INSET}>
2148
+ <VStack gap={4}>
2149
+ <HStack gap={2}>
2150
+ <StackItem size="fill">
2151
+ <VStack gap={1}>
2152
+ <Heading level={3}>{title}</Heading>
2153
+ <Text type="supporting" color="secondary">
2154
+ {subtitle}
2155
+ </Text>
2156
+ </VStack>
2157
+ </StackItem>
2158
+ <IconButton
2159
+ label={`Close ${title.toLowerCase()}`}
2160
+ variant="ghost"
2161
+ size="sm"
2162
+ icon={<Icon icon={X} size="sm" />}
2163
+ onClick={() => onOpenChange(false)}
2164
+ />
2165
+ </HStack>
2166
+ {children}
2167
+ </VStack>
2168
+ </Section>
2169
+ </BottomSheet>
2170
+ );
2171
+ }
2172
+
2173
+ return (
2174
+ <Dialog
2175
+ isOpen={isOpen}
2176
+ onOpenChange={onOpenChange}
2177
+ purpose="form"
2178
+ width={400}>
2179
+ <DialogHeader
2180
+ title={title}
2181
+ subtitle={subtitle}
2182
+ onOpenChange={onOpenChange}
2183
+ xstyle={styles.dialogHeaderBleed}
2184
+ />
2185
+ <Section variant="transparent" padding={4}>
2186
+ <VStack gap={4}>{children}</VStack>
2187
+ </Section>
2188
+ </Dialog>
2189
+ );
2190
+ }
2191
+
1853
2192
  // =============================================================================
1854
2193
  // Template
1855
2194
  // =============================================================================
1856
2195
 
1857
2196
  export default function TableFilterTemplate() {
2197
+ // Named for the surface, not the size: `isCompact` further down is the
2198
+ // density setting, which is a different thing entirely.
2199
+ //
2200
+ // Read through useSyncExternalStore, so this is the real match on the first
2201
+ // client render rather than a default that corrects itself afterwards —
2202
+ // which is what lets the auto-open effect below trust it.
2203
+ const isCompactSurface = useMediaQuery(COMPACT_SURFACE_QUERY);
2204
+
1858
2205
  // --- Filtering -------------------------------------------------------------
1859
2206
  const [filters, setFilters] = useState<PowerSearchFilter[]>(INITIAL_FILTERS);
1860
2207
  const [isPowerSearch, setIsPowerSearch] = useState(false);
@@ -1907,6 +2254,13 @@ export default function TableFilterTemplate() {
1907
2254
  const suppressGripClickRef = useRef(false);
1908
2255
  const announce = useAnnounce();
1909
2256
  const columnPanelId = useId();
2257
+ /**
2258
+ * Only the sheet's strip is a tablist, so only its panel carries this id.
2259
+ * The popover renders the same body without one, which is what keeps the id
2260
+ * unique: a closed BottomSheet still renders its children, so both copies of
2261
+ * the panel are in the document at once whenever the popover is open.
2262
+ */
2263
+ const viewPanelId = useId();
1910
2264
  /** Group keys the reader has folded shut. Empty means every section is open. */
1911
2265
  const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
1912
2266
  () => new Set(),
@@ -2254,12 +2608,34 @@ export default function TableFilterTemplate() {
2254
2608
  allowUnsortedState: true,
2255
2609
  isMultiSortEnabled: true,
2256
2610
  });
2611
+ /**
2612
+ * A frozen column is dropped on a compact surface, whichever edge it is on.
2613
+ *
2614
+ * Freezing trades scrollable width for a column that stays put, and that is
2615
+ * a good trade only while there is width left to scroll. The table is around
2616
+ * 1300px; on a 393px screen a pinned Job column takes most of what there is
2617
+ * and leaves a strip too narrow to read a second column in — the reader
2618
+ * scrolls a sliver past a column they were already looking at.
2619
+ *
2620
+ * Derived rather than written back into the view: the setting is the
2621
+ * reader's, and a phone should not silently rewrite it or save it into their
2622
+ * next saved view. The Sticky Columns panel reads these same two values and
2623
+ * disables itself on compact, so the control shows what is in effect instead
2624
+ * of promising a freeze that is not happening.
2625
+ */
2626
+ const effectiveStickyStart: StickyEdge = isCompactSurface
2627
+ ? 'none'
2628
+ : view.stickyStart;
2629
+ const effectiveStickyEnd: StickyEdge = isCompactSurface
2630
+ ? 'none'
2631
+ : view.stickyEnd;
2632
+
2257
2633
  // The checkbox column needs no mention here: the plugin pins the whole
2258
2634
  // contiguous run from the first column through the last key it is given, so
2259
2635
  // naming the first data column carries the selection column with it.
2260
2636
  const stickyPlugin = useTableStickyColumns<ServiceJob>({
2261
- startKeys: stickyKeys(view.stickyStart, view.columnKeys, false),
2262
- endKeys: stickyKeys(view.stickyEnd, view.columnKeys, true),
2637
+ startKeys: stickyKeys(effectiveStickyStart, view.columnKeys, false),
2638
+ endKeys: stickyKeys(effectiveStickyEnd, view.columnKeys, true),
2263
2639
  });
2264
2640
 
2265
2641
  // --- Grouping --------------------------------------------------------------
@@ -2306,30 +2682,6 @@ export default function TableFilterTemplate() {
2306
2682
  [groupRowKey],
2307
2683
  );
2308
2684
 
2309
- /**
2310
- * The panel opens on the first row, so the template arrives showing its
2311
- * detail view instead of a bare rail the reader has to discover. It reads
2312
- * the grouped order rather than `results` because that is the order on
2313
- * screen, and skips the synthetic section headers.
2314
- *
2315
- * One-shot through a ref: without it, every filter change would reopen the
2316
- * panel, and closing it would only make the next render open it again.
2317
- */
2318
- const hasOpenedFirstRow = useRef(false);
2319
- useEffect(() => {
2320
- if (hasOpenedFirstRow.current) {
2321
- return;
2322
- }
2323
- const first = (isGrouped ? groupedRows : rows).find(
2324
- row => !isGroupHeaderRow(row),
2325
- );
2326
- if (first == null) {
2327
- return;
2328
- }
2329
- hasOpenedFirstRow.current = true;
2330
- setActiveJobId(first.id);
2331
- }, [groupedRows, isGroupHeaderRow, isGrouped, rows]);
2332
-
2333
2685
  /**
2334
2686
  * A section header is a synthetic row, and the table renders every column's
2335
2687
  * cell against it before the plugin can discard them. Its fields read as
@@ -3056,58 +3408,86 @@ export default function TableFilterTemplate() {
3056
3408
  <HStack
3057
3409
  gap={2}
3058
3410
  vAlign="center"
3059
- wrap="wrap"
3411
+ wrap="nowrap"
3060
3412
  minHeight={32}
3061
3413
  xstyle={styles.filterRow}>
3062
3414
  {/* Search leads the row: it is the broadest filter, and anchoring it at
3063
3415
  the start keeps a fixed-width control on the first line. It sits
3064
3416
  outside the overflow list so it is never the clause that collapses —
3065
3417
  a hidden search box reads as a missing feature, not a folded one. */}
3066
- <TextInput
3067
- label="Search jobs"
3068
- isLabelHidden
3069
- placeholder="Job name"
3070
- size="sm"
3071
- value={query}
3072
- onChange={setQuery}
3073
- startIcon={Search}
3074
- xstyle={styles.searchInput}
3075
- />
3418
+ <StackItem xstyle={styles.searchSlot}>
3419
+ <TextInput
3420
+ label="Search jobs"
3421
+ isLabelHidden
3422
+ placeholder="Job name"
3423
+ size="sm"
3424
+ width="100%"
3425
+ value={query}
3426
+ onChange={setQuery}
3427
+ startIcon={Search}
3428
+ />
3429
+ </StackItem>
3430
+
3431
+ {/* The slot, not the list, is what carries the sizing. A list that reads
3432
+ its own width is reading the thing it just changed — collapse a
3433
+ clause and the measurement shrinks with it — so `observeParent` sends
3434
+ it up here instead, and this wrapper is then the piece that has to be
3435
+ sized right: from the row, never from the clauses inside it. See
3436
+ filterOverflowSlot for what that takes and what it looked like when
3437
+ the wrapper was content-sized too.
3438
+
3439
+ The readout lives in here with the list for the same reason, so that
3440
+ sizing this wrapper from the row does not drag the readout out to the
3441
+ row's edge with it. */}
3442
+ <StackItem xstyle={styles.filterOverflowSlot}>
3443
+ <OverflowList
3444
+ behavior="observeParent"
3445
+ gap={2}
3446
+ /* One clause stays on the row where there is width for it: a bar
3447
+ that collapses to nothing but a count reads as though the filters
3448
+ are gone rather than folded. A phone has no such width — search,
3449
+ one clause and the readout do not fit across 393px — so there the
3450
+ floor comes off and the whole set folds behind the count. */
3451
+ minVisibleItems={isCompactSurface ? 0 : 1}
3452
+ overflowRenderer={overflowItems => (
3453
+ <FilterOverflow
3454
+ items={overflowItems}
3455
+ isCompactSurface={isCompactSurface}
3456
+ />
3457
+ )}>
3458
+ {filterControls}
3459
+ </OverflowList>
3460
+
3461
+ {/* The row's readout, kept as one group directly after the last clause
3462
+ rather than pushed to the far edge. Grouping also stops the
3463
+ separator stranding itself on a line of its own.
3464
+
3465
+ Inside the slot rather than beside it: a sibling would sit after
3466
+ the slot, and the slot spans the row's leftover, so the readout
3467
+ would land at the trailing edge however few clauses were showing.
3468
+ In here it follows the clauses, and the reserve is what stops the
3469
+ list from spending the width it needs. */}
3470
+ <HStack gap={2} vAlign="center" xstyle={styles.filterMeta}>
3471
+ {powerSearchToggle}
3076
3472
 
3077
- {filterControls}
3078
-
3079
- {/* The row's readout, kept as one group directly after the last clause
3080
- rather than pushed to the far edge. Grouping also stops the separator
3081
- strandng itself on a line of its own when the row wraps.
3082
-
3083
- NOTE: this is why there is no overflow list here. OverflowList reads
3084
- its own offsetWidth to decide what fits, so it only reports an
3085
- overflow while it owns the row's leftover width — and owning that
3086
- width is exactly what pushes this group to the far right. Sized to
3087
- its content instead, it shrinks as it collapses, so its measurement
3088
- shrinks with it and it never re-expands: once collapsed at 600px it
3089
- stayed collapsed all the way back out to 1680px. Wrapping is the
3090
- behaviour that keeps this group where it belongs. */}
3091
- <HStack gap={2} vAlign="center" xstyle={styles.filterMeta}>
3092
- {powerSearchToggle}
3093
-
3094
- <Text type="supporting" color="secondary">
3095
- {results.length} {results.length === 1 ? 'result' : 'results'}
3096
- </Text>
3473
+ <Text type="supporting" color="secondary">
3474
+ {results.length} {results.length === 1 ? 'result' : 'results'}
3475
+ </Text>
3097
3476
 
3098
- {hasFilters && (
3099
- <>
3100
- <Text type="supporting" color="secondary">
3101
- •
3102
- </Text>
3103
- {/* Href-less, so Link renders a <button>: this resets the filter
3104
- array, it does not navigate. */}
3105
- <Link type="supporting" onClick={clearAll}>
3106
- Clear all
3107
- </Link>
3108
- </>
3109
- )}
3110
- </HStack>
3477
+ {hasFilters && (
3478
+ <>
3479
+ <Text type="supporting" color="secondary">
3480
+ •
3481
+ </Text>
3482
+ {/* Href-less, so Link renders a <button>: this resets the filter
3483
+ array, it does not navigate. */}
3484
+ <Link type="supporting" onClick={clearAll}>
3485
+ Clear all
3486
+ </Link>
3487
+ </>
3488
+ )}
3489
+ </HStack>
3490
+ </StackItem>
3111
3491
  </HStack>
3112
3492
  );
3113
3493
 
@@ -3115,66 +3495,87 @@ export default function TableFilterTemplate() {
3115
3495
  <HStack
3116
3496
  gap={2}
3117
3497
  vAlign="center"
3118
- wrap="wrap"
3498
+ wrap="nowrap"
3119
3499
  minHeight={32}
3120
3500
  xstyle={styles.bar}>
3121
- <Text type="label">Saved views:</Text>
3122
- {/* Exactly one of these is engaged at a time, so All is a member of the
3123
- same group rather than a differently-shaped reset button. It carries
3124
- no icon because it stands for the absence of a saved view. */}
3125
- <ToggleButton
3126
- label="All"
3127
- size="sm"
3128
- isPressed={activeSavedViewId == null}
3129
- xstyle={[
3130
- styles.filterChrome,
3131
- activeSavedViewId == null
3132
- ? styles.filterLabelValue
3133
- : styles.filterLabelEmpty,
3134
- activeSavedViewId != null && styles.filterSurface,
3135
- ]}
3136
- onPressedChange={() => applySavedView(null)}
3137
- />
3138
- {savedViews.map(saved => {
3139
- const isPressed = activeSavedViewId === saved.id;
3140
- return (
3501
+ {/* Outside the scroller, so the row keeps saying what it is however far
3502
+ the chips are pushed along. */}
3503
+ <Text type="label" xstyle={styles.savedViewsLabel}>
3504
+ Saved views:
3505
+ </Text>
3506
+ {/* Same slot-and-content split the filter row uses, for the same reason:
3507
+ the slot is the piece that shrinks, and the scroller inside reads the
3508
+ width it is left with. Scrolled rather than folded into a count,
3509
+ unlike the clauses — a saved view is picked by recognising its name,
3510
+ so a set that hides behind "+3" cannot be browsed, only counted. */}
3511
+ <StackItem xstyle={styles.savedViewsSlot}>
3512
+ <HStack
3513
+ gap={2}
3514
+ vAlign="center"
3515
+ wrap="nowrap"
3516
+ xstyle={styles.savedViewsScroller}>
3517
+ {/* Exactly one of these is engaged at a time, so All is a member of
3518
+ the same group rather than a differently-shaped reset button. It
3519
+ carries no icon because it stands for the absence of a saved
3520
+ view. */}
3141
3521
  <ToggleButton
3142
- key={saved.id}
3143
- label={saved.name}
3522
+ label="All"
3144
3523
  size="sm"
3145
- isPressed={isPressed}
3146
- icon={<Icon icon={Bookmark} size="sm" />}
3147
- // Same chrome the filter-bar presets wear: a ghost Button has none
3148
- // unpressed, and the border is what keeps these in the same family
3149
- // as the unset selectors in the row above.
3524
+ isPressed={activeSavedViewId == null}
3150
3525
  xstyle={[
3526
+ styles.savedViewChip,
3151
3527
  styles.filterChrome,
3152
- isPressed ? styles.filterLabelValue : styles.filterLabelEmpty,
3153
- !isPressed && styles.filterSurface,
3528
+ activeSavedViewId == null
3529
+ ? styles.filterLabelValue
3530
+ : styles.filterLabelEmpty,
3531
+ activeSavedViewId != null && styles.filterSurface,
3154
3532
  ]}
3155
- // Unpressing lands on the state the All chip already models:
3156
- // applySavedView(null) drops the active id and its filters
3157
- // together, so the two controls can never disagree about what is
3158
- // applied.
3159
- onPressedChange={next => applySavedView(next ? saved : null)}
3533
+ onPressedChange={() => applySavedView(null)}
3160
3534
  />
3161
- );
3162
- })}
3163
- {activeSavedViewId != null && (
3164
- <IconButton
3165
- label="Edit saved view"
3166
- tooltip="Edit saved view"
3167
- variant="ghost"
3168
- size="sm"
3169
- icon={<Icon icon={Pencil} size="sm" />}
3170
- onClick={() => {
3171
- const found = savedViews.find(s => s.id === activeSavedViewId);
3172
- if (found) {
3173
- setEditing({...found});
3174
- }
3175
- }}
3176
- />
3177
- )}
3535
+ {savedViews.map(saved => {
3536
+ const isPressed = activeSavedViewId === saved.id;
3537
+ return (
3538
+ <ToggleButton
3539
+ key={saved.id}
3540
+ label={saved.name}
3541
+ size="sm"
3542
+ isPressed={isPressed}
3543
+ icon={<Icon icon={Bookmark} size="sm" />}
3544
+ // Same chrome the filter-bar presets wear: a ghost Button has
3545
+ // none unpressed, and the border is what keeps these in the
3546
+ // same family as the unset selectors in the row above.
3547
+ xstyle={[
3548
+ styles.savedViewChip,
3549
+ styles.filterChrome,
3550
+ isPressed ? styles.filterLabelValue : styles.filterLabelEmpty,
3551
+ !isPressed && styles.filterSurface,
3552
+ ]}
3553
+ // Unpressing lands on the state the All chip already models:
3554
+ // applySavedView(null) drops the active id and its filters
3555
+ // together, so the two controls can never disagree about what
3556
+ // is applied.
3557
+ onPressedChange={next => applySavedView(next ? saved : null)}
3558
+ />
3559
+ );
3560
+ })}
3561
+ {activeSavedViewId != null && (
3562
+ <IconButton
3563
+ label="Edit saved view"
3564
+ tooltip="Edit saved view"
3565
+ variant="ghost"
3566
+ size="sm"
3567
+ icon={<Icon icon={Pencil} size="sm" />}
3568
+ xstyle={styles.savedViewChip}
3569
+ onClick={() => {
3570
+ const found = savedViews.find(s => s.id === activeSavedViewId);
3571
+ if (found) {
3572
+ setEditing({...found});
3573
+ }
3574
+ }}
3575
+ />
3576
+ )}
3577
+ </HStack>
3578
+ </StackItem>
3178
3579
  </HStack>
3179
3580
  );
3180
3581
 
@@ -3515,9 +3916,17 @@ export default function TableFilterTemplate() {
3515
3916
  case 'sticky':
3516
3917
  return (
3517
3918
  <VStack gap={4} paddingInline={4} paddingBlockEnd={4}>
3919
+ {/* Both groups read the effective value, not the stored one, and
3920
+ go disabled where the two differ — a radio still sitting on
3921
+ "First column" while nothing is frozen is the control lying
3922
+ about the table. `disabledMessage` is what says why, and it is
3923
+ the sanctioned way to do it here: a disabled control swallows
3924
+ the hover a wrapping Tooltip would need. */}
3518
3925
  <RadioList
3519
3926
  label="First columns"
3520
- value={view.stickyStart}
3927
+ value={effectiveStickyStart}
3928
+ isDisabled={isCompactSurface}
3929
+ disabledMessage="Freezing is off on a narrow screen: a pinned column would leave too little width to scroll the rest."
3521
3930
  onChange={value =>
3522
3931
  updateView(v => ({...v, stickyStart: value as StickyEdge}))
3523
3932
  }>
@@ -3531,7 +3940,9 @@ export default function TableFilterTemplate() {
3531
3940
  </RadioList>
3532
3941
  <RadioList
3533
3942
  label="Last columns"
3534
- value={view.stickyEnd}
3943
+ value={effectiveStickyEnd}
3944
+ isDisabled={isCompactSurface}
3945
+ disabledMessage="Freezing is off on a narrow screen: a pinned column would leave too little width to scroll the rest."
3535
3946
  onChange={value =>
3536
3947
  updateView(v => ({...v, stickyEnd: value as StickyEdge}))
3537
3948
  }>
@@ -3571,6 +3982,29 @@ export default function TableFilterTemplate() {
3571
3982
 
3572
3983
  const activeSection = VIEW_SECTIONS.find(s => s.key === viewSection);
3573
3984
 
3985
+ /**
3986
+ * Flat rather than outlined: ghost is the borderless variant Button and
3987
+ * Selector share, so this trigger reads as the same family of control as a
3988
+ * ghost Selector — chevron and all — without the local filterChrome the
3989
+ * bordered toggles in the row need.
3990
+ *
3991
+ * Deliberately not a ComplexSelector, unlike the Quote control: this panel
3992
+ * drives a whole ViewState and has no single value to hand a value-shaped
3993
+ * component.
3994
+ *
3995
+ * As a Popover's child the click is the Popover's to handle, so `onClick` is
3996
+ * only wired on the branch where this button owns the open state itself.
3997
+ */
3998
+ const viewOptionsTrigger = (
3999
+ <Button
4000
+ label="View options"
4001
+ variant="ghost"
4002
+ size="sm"
4003
+ endContent={<Icon icon={ChevronDown} size="sm" />}
4004
+ onClick={isCompactSurface ? () => setIsViewOpen(true) : undefined}
4005
+ />
4006
+ );
4007
+
3574
4008
  /**
3575
4009
  * The popover container is flush (see styles.viewPopoverSurface), so the
3576
4010
  * rail's divider runs the full height of the body. Nothing sits against a
@@ -3620,12 +4054,232 @@ export default function TableFilterTemplate() {
3620
4054
  </VStack>
3621
4055
  );
3622
4056
 
4057
+ /**
4058
+ * The same four sections on a phone, where the popover's shape is the thing
4059
+ * that fails: 660px of rail-beside-pane squeezed into 393px leaves the
4060
+ * column lists about 75px each, which is narrower than the words in them.
4061
+ *
4062
+ * The rail turns horizontal and becomes a TabList, which is the component
4063
+ * that already owns this: a strip narrower than its tabs scrolls, the
4064
+ * selected one is kept in view, and the edge fade says there is more. That
4065
+ * last part is why this is a strip and not a wrap — a bare scroller would
4066
+ * hide sections behind a gesture with nothing on screen to suggest it, and
4067
+ * the fade is the affordance that answers it. Below it the pane gets the
4068
+ * full width, which is what the column lists needed.
4069
+ *
4070
+ * `overflow` is left at its default rather than pinned to 'scroll': 'auto'
4071
+ * is the component choosing, so a future strip that folds into a menu
4072
+ * instead reaches this template without an edit here.
4073
+ *
4074
+ * `role="tablist"` rather than the default nav landmark: these tabs swap a
4075
+ * panel in place instead of navigating, so each one points at the panel via
4076
+ * panelId and the panel answers to it below.
4077
+ *
4078
+ * `tall` rather than `capped`: Columns is a two-panel transfer list, and the
4079
+ * mid-height budget would spend most of itself on the rail and heading and
4080
+ * leave a few rows visible under them.
4081
+ */
4082
+ const viewOptionsSheet = (
4083
+ <BottomSheet
4084
+ // The popover and the sheet read the same open flag, so the branch has
4085
+ // to be in the condition: without it, opening the panel with a pointer
4086
+ // would raise the sheet behind the popover as well.
4087
+ isOpen={isCompactSurface && isViewOpen}
4088
+ onOpenChange={setIsViewOpen}
4089
+ label="View options"
4090
+ height="tall">
4091
+ {/* The inset is the wrapper's, so the strip's own box starts at the tab
4092
+ row and its scroll arrow lands on the tabs rather than above them. */}
4093
+ <VStack gap={0} paddingBlockStart={SHEET_TOP_INSET}>
4094
+ {/* The heading the popover puts above the pane is dropped here: it
4095
+ restates the selected tab word for word, and the strip already
4096
+ marks which one that is. On a phone that is a line of vertical
4097
+ space spent saying it twice. The panel keeps the name for anyone
4098
+ not seeing the strip, as its accessible label. */}
4099
+ <TabList
4100
+ value={viewSection}
4101
+ onChange={value => setViewSection(value as ViewSection)}
4102
+ role="tablist"
4103
+ hasDivider
4104
+ xstyle={styles.viewSheetTabs}>
4105
+ {VIEW_SECTIONS.map(section => (
4106
+ <Tab
4107
+ key={section.key}
4108
+ value={section.key}
4109
+ label={section.label}
4110
+ icon={section.icon}
4111
+ panelId={viewPanelId}
4112
+ />
4113
+ ))}
4114
+ </TabList>
4115
+
4116
+ {/* The panels set their own inline gutter and bottom padding but no
4117
+ top one, because in the popover the dropped heading was the thing
4118
+ separating them from what is above. Here the strip's rule takes
4119
+ that place, so the gap it left is restated at the same 12px the
4120
+ heading gave it. */}
4121
+ <VStack
4122
+ gap={0}
4123
+ id={viewPanelId}
4124
+ role="tabpanel"
4125
+ aria-label={activeSection?.title}
4126
+ paddingBlockStart={3}>
4127
+ {viewPanelBody()}
4128
+ </VStack>
4129
+ </VStack>
4130
+ </BottomSheet>
4131
+ );
4132
+
3623
4133
  // ---------------------------------------------------------------------------
3624
4134
  // Detail panel
3625
4135
  // ---------------------------------------------------------------------------
3626
4136
 
4137
+ /**
4138
+ * The panel's contents, held apart from the panel itself so the sheet can
4139
+ * show the same thing. Nothing in here is aware of which one it is in: it is
4140
+ * a column of sections against a 16px gutter either way, and both hosts
4141
+ * carry no padding of their own for it to fight.
4142
+ */
4143
+ const detailBody =
4144
+ activeJob == null ? null : (
4145
+ <VStack gap={0} xstyle={styles.detailPanel}>
4146
+ {/* The only part of the body that knows which host it is in: the sheet
4147
+ needs the pill cleared, the docked panel has no pill and would just
4148
+ sit 8px lower than the panels beside it. The two never coexist —
4149
+ Layout drops the panel entirely on a compact surface. */}
4150
+ <Section
4151
+ variant="transparent"
4152
+ padding={4}
4153
+ paddingBlockStart={isCompactSurface ? SHEET_TOP_INSET : 4}>
4154
+ <VStack gap={4}>
4155
+ <HStack gap={2}>
4156
+ <StackItem size="fill">
4157
+ <VStack gap={2}>
4158
+ <HStack gap={2} vAlign="center">
4159
+ <Badge
4160
+ variant={STATUS_META[activeJob.status].badge}
4161
+ label={STATUS_META[activeJob.status].label}
4162
+ />
4163
+ <StatusDot
4164
+ variant={PRIORITY_META[activeJob.priority].dot}
4165
+ label={`${PRIORITY_META[activeJob.priority].label} priority`}
4166
+ isPulsing={activeJob.priority === 'urgent'}
4167
+ />
4168
+ <Text type="supporting" color="secondary">
4169
+ {PRIORITY_META[activeJob.priority].label} · {activeJob.id}
4170
+ </Text>
4171
+ </HStack>
4172
+ <Heading level={2}>{activeJob.summary}</Heading>
4173
+ </VStack>
4174
+ </StackItem>
4175
+ <IconButton
4176
+ label="Close details"
4177
+ variant="ghost"
4178
+ size="sm"
4179
+ icon={<Icon icon={X} size="sm" />}
4180
+ onClick={() => setActiveJobId(null)}
4181
+ />
4182
+ </HStack>
4183
+
4184
+ {/* Both take the full width, so flex shrink splits the row
4185
+ evenly however wide the panel is dragged. */}
4186
+ <HStack gap={2}>
4187
+ <Button
4188
+ label={NEXT_ACTION[activeJob.status]}
4189
+ size="sm"
4190
+ width="100%"
4191
+ />
4192
+ <Button
4193
+ label="Edit job"
4194
+ variant="secondary"
4195
+ size="sm"
4196
+ width="100%"
4197
+ />
4198
+ </HStack>
4199
+ </VStack>
4200
+ </Section>
4201
+
4202
+ <Divider />
4203
+
4204
+ <Section variant="transparent" padding={4}>
4205
+ <MetadataList
4206
+ columns="single"
4207
+ label={{position: 'start', width: 104}}>
4208
+ <MetadataListItem label="Customer">
4209
+ <Text type="body">{activeJob.customer}</Text>
4210
+ </MetadataListItem>
4211
+ <MetadataListItem label="Site address">
4212
+ <Text type="body">{activeJob.address}</Text>
4213
+ </MetadataListItem>
4214
+ <MetadataListItem label="Technician">
4215
+ <Text type="body">{activeJob.technician}</Text>
4216
+ </MetadataListItem>
4217
+ <MetadataListItem label="Scheduled">
4218
+ <Text type="body">
4219
+ {formatDate(activeJob.scheduledAt, true)},{' '}
4220
+ {formatTime(activeJob.scheduledAt)}
4221
+ </Text>
4222
+ </MetadataListItem>
4223
+ <MetadataListItem label="Equipment">
4224
+ <Text type="body">{activeJob.equipment}</Text>
4225
+ </MetadataListItem>
4226
+ <MetadataListItem label="Quote">
4227
+ <Text type="body">{formatMoney(activeJob.quoted)}</Text>
4228
+ </MetadataListItem>
4229
+ </MetadataList>
4230
+ </Section>
4231
+
4232
+ <Divider />
4233
+
4234
+ <Section variant="transparent" padding={4}>
4235
+ <VStack gap={2}>
4236
+ <Heading level={3}>Site history</Heading>
4237
+ {siteHistory.length === 0 ? (
4238
+ <Text type="supporting" color="secondary">
4239
+ No other visits on file for this site.
4240
+ </Text>
4241
+ ) : (
4242
+ <VStack gap={0}>
4243
+ {siteHistory.map(related => (
4244
+ <Item
4245
+ key={related.id}
4246
+ align="center"
4247
+ label={related.summary}
4248
+ labelLines={2}
4249
+ // The date leads: this is a list of visits to one site,
4250
+ // so when it happened is what tells them apart. The
4251
+ // badge takes the end slot the date used to hold —
4252
+ // a badge is too wide to lead a row this narrow.
4253
+ description={`${formatDate(related.scheduledAt, true)} · ${related.technician}`}
4254
+ endContent={
4255
+ <Badge
4256
+ variant={STATUS_META[related.status].badge}
4257
+ label={STATUS_META[related.status].label}
4258
+ />
4259
+ }
4260
+ onClick={() => setActiveJobId(related.id)}
4261
+ />
4262
+ ))}
4263
+ </VStack>
4264
+ )}
4265
+ </VStack>
4266
+ </Section>
4267
+ </VStack>
4268
+ );
4269
+
4270
+ /**
4271
+ * With a pointer the details are a resizable column beside the table, so the
4272
+ * row and its detail stay on screen together and the reader can widen one
4273
+ * against the other.
4274
+ *
4275
+ * A phone has no width to give: the same panel there would leave the table
4276
+ * as a sliver of checkboxes behind it, which is what it did before this
4277
+ * branch existed. A sheet takes the block axis instead — the axis a phone
4278
+ * has to spare — so the table stays whole underneath and the details come
4279
+ * back down with a swipe.
4280
+ */
3627
4281
  const detailPanel =
3628
- activeJob == null ? undefined : (
4282
+ detailBody == null ? undefined : (
3629
4283
  <>
3630
4284
  {/* The panel owns the separator, so the handle stays divider-less and
3631
4285
  hides its pill at rest rather than floating a stray stub. */}
@@ -3635,134 +4289,35 @@ export default function TableFilterTemplate() {
3635
4289
  isAlwaysVisible={false}
3636
4290
  label="Resize job details"
3637
4291
  />
4292
+ {/* Panel padding is 0 so the dividers reach both edges; each section
4293
+ inside re-adds the 16px gutter to keep its content on the same
4294
+ line. */}
3638
4295
  <LayoutPanel
3639
4296
  resizable={detailWidth.props}
3640
4297
  hasDivider
3641
4298
  padding={0}
3642
4299
  label="Job details">
3643
- {/* Panel padding is 0 so the dividers reach both edges; each section
3644
- re-adds the 16px gutter to keep its content on the same line. */}
3645
- <VStack gap={0} xstyle={styles.detailPanel}>
3646
- <Section variant="transparent" padding={4}>
3647
- <VStack gap={4}>
3648
- <HStack gap={2}>
3649
- <StackItem size="fill">
3650
- <VStack gap={2}>
3651
- <HStack gap={2} vAlign="center">
3652
- <Badge
3653
- variant={STATUS_META[activeJob.status].badge}
3654
- label={STATUS_META[activeJob.status].label}
3655
- />
3656
- <StatusDot
3657
- variant={PRIORITY_META[activeJob.priority].dot}
3658
- label={`${PRIORITY_META[activeJob.priority].label} priority`}
3659
- isPulsing={activeJob.priority === 'urgent'}
3660
- />
3661
- <Text type="supporting" color="secondary">
3662
- {PRIORITY_META[activeJob.priority].label} ·{' '}
3663
- {activeJob.id}
3664
- </Text>
3665
- </HStack>
3666
- <Heading level={2}>{activeJob.summary}</Heading>
3667
- </VStack>
3668
- </StackItem>
3669
- <IconButton
3670
- label="Close details"
3671
- variant="ghost"
3672
- size="sm"
3673
- icon={<Icon icon={X} size="sm" />}
3674
- onClick={() => setActiveJobId(null)}
3675
- />
3676
- </HStack>
3677
-
3678
- {/* Both take the full width, so flex shrink splits the row
3679
- evenly however wide the panel is dragged. */}
3680
- <HStack gap={2}>
3681
- <Button
3682
- label={NEXT_ACTION[activeJob.status]}
3683
- size="sm"
3684
- width="100%"
3685
- />
3686
- <Button
3687
- label="Edit job"
3688
- variant="secondary"
3689
- size="sm"
3690
- width="100%"
3691
- />
3692
- </HStack>
3693
- </VStack>
3694
- </Section>
3695
-
3696
- <Divider />
3697
-
3698
- <Section variant="transparent" padding={4}>
3699
- <MetadataList
3700
- columns="single"
3701
- label={{position: 'start', width: 104}}>
3702
- <MetadataListItem label="Customer">
3703
- <Text type="body">{activeJob.customer}</Text>
3704
- </MetadataListItem>
3705
- <MetadataListItem label="Site address">
3706
- <Text type="body">{activeJob.address}</Text>
3707
- </MetadataListItem>
3708
- <MetadataListItem label="Technician">
3709
- <Text type="body">{activeJob.technician}</Text>
3710
- </MetadataListItem>
3711
- <MetadataListItem label="Scheduled">
3712
- <Text type="body">
3713
- {formatDate(activeJob.scheduledAt, true)},{' '}
3714
- {formatTime(activeJob.scheduledAt)}
3715
- </Text>
3716
- </MetadataListItem>
3717
- <MetadataListItem label="Equipment">
3718
- <Text type="body">{activeJob.equipment}</Text>
3719
- </MetadataListItem>
3720
- <MetadataListItem label="Quote">
3721
- <Text type="body">{formatMoney(activeJob.quoted)}</Text>
3722
- </MetadataListItem>
3723
- </MetadataList>
3724
- </Section>
3725
-
3726
- <Divider />
3727
-
3728
- <Section variant="transparent" padding={4}>
3729
- <VStack gap={2}>
3730
- <Heading level={3}>Site history</Heading>
3731
- {siteHistory.length === 0 ? (
3732
- <Text type="supporting" color="secondary">
3733
- No other visits on file for this site.
3734
- </Text>
3735
- ) : (
3736
- <VStack gap={0}>
3737
- {siteHistory.map(related => (
3738
- <Item
3739
- key={related.id}
3740
- align="center"
3741
- label={related.summary}
3742
- labelLines={2}
3743
- // The date leads: this is a list of visits to one site,
3744
- // so when it happened is what tells them apart. The
3745
- // badge takes the end slot the date used to hold —
3746
- // a badge is too wide to lead a row this narrow.
3747
- description={`${formatDate(related.scheduledAt, true)} · ${related.technician}`}
3748
- endContent={
3749
- <Badge
3750
- variant={STATUS_META[related.status].badge}
3751
- label={STATUS_META[related.status].label}
3752
- />
3753
- }
3754
- onClick={() => setActiveJobId(related.id)}
3755
- />
3756
- ))}
3757
- </VStack>
3758
- )}
3759
- </VStack>
3760
- </Section>
3761
- </VStack>
4300
+ {detailBody}
3762
4301
  </LayoutPanel>
3763
4302
  </>
3764
4303
  );
3765
4304
 
4305
+ /**
4306
+ * `tall` because this is the reading surface, not a picker: the metadata
4307
+ * list and the site history below it are what the reader opened the row for,
4308
+ * and a mid-height budget would put the history under a scroll before they
4309
+ * had seen there was any.
4310
+ */
4311
+ const detailSheet = (
4312
+ <BottomSheet
4313
+ isOpen={isCompactSurface && activeJob != null}
4314
+ onOpenChange={open => !open && setActiveJobId(null)}
4315
+ label="Job details"
4316
+ height="tall">
4317
+ {detailBody}
4318
+ </BottomSheet>
4319
+ );
4320
+
3766
4321
  // ---------------------------------------------------------------------------
3767
4322
  // Render
3768
4323
  // ---------------------------------------------------------------------------
@@ -3773,7 +4328,7 @@ export default function TableFilterTemplate() {
3773
4328
  height="fill"
3774
4329
  padding={0}
3775
4330
  xstyle={styles.pageShell}
3776
- end={detailPanel}
4331
+ end={isCompactSurface ? undefined : detailPanel}
3777
4332
  header={
3778
4333
  <LayoutHeader hasDivider label="Job filters and table actions">
3779
4334
  <VStack gap={0} xstyle={styles.headerWrap}>
@@ -3849,35 +4404,26 @@ export default function TableFilterTemplate() {
3849
4404
  of View options when the bar switches. */}
3850
4405
  {isPowerSearch && powerSearchToggle}
3851
4406
 
3852
- <Popover
3853
- placement="below"
3854
- alignment="end"
3855
- width={660}
3856
- label="View options"
3857
- isOpen={isViewOpen}
3858
- onOpenChange={setIsViewOpen}
3859
- xstyle={styles.viewPopoverSurface}
3860
- content={viewOptionsPopover}>
3861
- {/* Flat rather than outlined: ghost is the
3862
- borderless variant Button and Selector share, so
3863
- this trigger reads as the same family of control
3864
- as a ghost Selector — chevron and all — without
3865
- the local filterChrome the bordered toggles in
3866
- the row need.
3867
-
3868
- Deliberately not a ComplexSelector, unlike the
3869
- Quote control: this panel drives a whole
3870
- ViewState and has no single value to hand a
3871
- value-shaped component. */}
3872
- <Button
4407
+ {/* Same trigger either way; only what it opens
4408
+ changes. On a phone the panel is the sheet
4409
+ rendered beside the dialogs below, so the button
4410
+ drives `isViewOpen` directly instead of being a
4411
+ Popover's child. */}
4412
+ {isCompactSurface ? (
4413
+ viewOptionsTrigger
4414
+ ) : (
4415
+ <Popover
4416
+ placement="below"
4417
+ alignment="end"
4418
+ width={660}
3873
4419
  label="View options"
3874
- variant="ghost"
3875
- size="sm"
3876
- endContent={
3877
- <Icon icon={ChevronDown} size="sm" />
3878
- }
3879
- />
3880
- </Popover>
4420
+ isOpen={isViewOpen}
4421
+ onOpenChange={setIsViewOpen}
4422
+ xstyle={styles.viewPopoverSurface}
4423
+ content={viewOptionsPopover}>
4424
+ {viewOptionsTrigger}
4425
+ </Popover>
4426
+ )}
3881
4427
 
3882
4428
  {/* `tooltip` is stated rather than left to
3883
4429
  isIconOnly: the automatic tooltip the prop
@@ -3988,106 +4534,92 @@ export default function TableFilterTemplate() {
3988
4534
  }
3989
4535
  />
3990
4536
 
4537
+ {/* Mounted at every width rather than behind the branch: a closed
4538
+ BottomSheet is one hidden dialog element, and gating the mount would
4539
+ tear the panel down under a reader who rotates the phone mid-edit.
4540
+ The branch lives in its `isOpen` instead. */}
4541
+ {viewOptionsSheet}
4542
+ {detailSheet}
4543
+
3991
4544
  {/* Create saved view.
3992
4545
 
3993
- Dialog → DialogHeader + Section: two regions, 16px on each. The
3994
- Dialog keeps its --spacing-4 default rather than restating
4546
+ On the Dialog branch: DialogHeader + Section are two regions, 16px on
4547
+ each. The Dialog keeps its --spacing-4 default rather than restating
3995
4548
  padding={4}; either way it puts that gutter on its inner wrapper and
3996
4549
  republishes it, and the header would otherwise pad on top of it. The
3997
4550
  Section escapes the wrapper on its own; dialogHeaderBleed does the
3998
4551
  same for the header so both land on one 16px gutter. */}
3999
- <Dialog
4552
+ <AdaptiveSavedViewForm
4000
4553
  isOpen={creatingName != null}
4001
4554
  onOpenChange={open => setCreatingName(open ? '' : null)}
4002
- purpose="form"
4003
- width={400}>
4004
- <DialogHeader
4005
- title="Create new saved view"
4006
- subtitle="Captures the filters and the table configuration as they are now."
4007
- onOpenChange={open => setCreatingName(open ? '' : null)}
4008
- xstyle={styles.dialogHeaderBleed}
4555
+ title="Create new saved view"
4556
+ subtitle="Captures the filters and the table configuration as they are now."
4557
+ isCompactSurface={isCompactSurface}>
4558
+ <TextInput
4559
+ label="Name"
4560
+ value={creatingName ?? ''}
4561
+ onChange={setCreatingName}
4562
+ hasAutoFocus
4009
4563
  />
4010
- <Section variant="transparent" padding={4}>
4011
- <VStack gap={4}>
4012
- <TextInput
4013
- label="Name"
4014
- value={creatingName ?? ''}
4015
- onChange={setCreatingName}
4016
- hasAutoFocus
4017
- />
4018
- <VStack gap={1}>
4019
- <Text type="label">This view saves</Text>
4020
- <ViewSummaryList view={view} filters={filters} />
4021
- </VStack>
4022
- <HStack hAlign="end">
4023
- <Button
4024
- label="Create"
4025
- variant="primary"
4026
- isDisabled={(creatingName ?? '').trim() === ''}
4027
- onClick={() => createSavedView(creatingName ?? '')}
4028
- />
4029
- </HStack>
4030
- </VStack>
4031
- </Section>
4032
- </Dialog>
4564
+ <VStack gap={1}>
4565
+ <Text type="label">This view saves</Text>
4566
+ <ViewSummaryList view={view} filters={filters} />
4567
+ </VStack>
4568
+ <HStack hAlign="end">
4569
+ <Button
4570
+ label="Create"
4571
+ variant="primary"
4572
+ isDisabled={(creatingName ?? '').trim() === ''}
4573
+ onClick={() => createSavedView(creatingName ?? '')}
4574
+ />
4575
+ </HStack>
4576
+ </AdaptiveSavedViewForm>
4033
4577
 
4034
4578
  {/* Edit saved view */}
4035
- <Dialog
4579
+ <AdaptiveSavedViewForm
4036
4580
  isOpen={editing != null}
4037
4581
  onOpenChange={open => !open && setEditing(null)}
4038
- purpose="form"
4039
- width={400}>
4040
- <DialogHeader
4041
- title="Edit saved view"
4042
- subtitle="Renaming only. The configuration is what was captured when the view was saved."
4043
- onOpenChange={open => !open && setEditing(null)}
4044
- xstyle={styles.dialogHeaderBleed}
4582
+ title="Edit saved view"
4583
+ subtitle="Renaming only. The configuration is what was captured when the view was saved."
4584
+ isCompactSurface={isCompactSurface}>
4585
+ <TextInput
4586
+ label="Name"
4587
+ value={editing?.name ?? ''}
4588
+ onChange={value =>
4589
+ setEditing(current =>
4590
+ current ? {...current, name: value} : current,
4591
+ )
4592
+ }
4045
4593
  />
4046
- <Section variant="transparent" padding={4}>
4047
- <VStack gap={4}>
4048
- <TextInput
4049
- label="Name"
4050
- value={editing?.name ?? ''}
4051
- onChange={value =>
4052
- setEditing(current =>
4053
- current ? {...current, name: value} : current,
4054
- )
4055
- }
4056
- />
4057
- {/* The configuration is captured, not editable here: it is changed
4058
- by reconfiguring the table and saving a new view. */}
4059
- <VStack gap={1}>
4060
- <Text type="label">This view saves</Text>
4061
- {editing == null ? (
4062
- <Text type="supporting" color="secondary">
4063
- (Empty)
4064
- </Text>
4065
- ) : (
4066
- <ViewSummaryList
4067
- view={editing.view}
4068
- filters={editing.filters}
4069
- />
4070
- )}
4071
- </VStack>
4072
- <HStack gap={2} vAlign="center">
4594
+ {/* The configuration is captured, not editable here: it is changed
4595
+ by reconfiguring the table and saving a new view. */}
4596
+ <VStack gap={1}>
4597
+ <Text type="label">This view saves</Text>
4598
+ {editing == null ? (
4599
+ <Text type="supporting" color="secondary">
4600
+ (Empty)
4601
+ </Text>
4602
+ ) : (
4603
+ <ViewSummaryList view={editing.view} filters={editing.filters} />
4604
+ )}
4605
+ </VStack>
4606
+ <HStack gap={2} vAlign="center">
4607
+ <Button
4608
+ label="Delete"
4609
+ variant="destructive"
4610
+ onClick={() => editing && deleteSavedView(editing.id)}
4611
+ />
4612
+ <StackItem size="fill">
4613
+ <HStack hAlign="end">
4073
4614
  <Button
4074
- label="Delete"
4075
- variant="destructive"
4076
- onClick={() => editing && deleteSavedView(editing.id)}
4615
+ label="Save"
4616
+ variant="primary"
4617
+ onClick={() => editing && saveEditedView(editing)}
4077
4618
  />
4078
- <StackItem size="fill">
4079
- <HStack hAlign="end">
4080
- <Button
4081
- label="Save"
4082
- variant="primary"
4083
- onClick={() => editing && saveEditedView(editing)}
4084
- />
4085
- </HStack>
4086
- </StackItem>
4087
4619
  </HStack>
4088
- </VStack>
4089
- </Section>
4090
- </Dialog>
4620
+ </StackItem>
4621
+ </HStack>
4622
+ </AdaptiveSavedViewForm>
4091
4623
  </>
4092
4624
  );
4093
4625
  }