@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.
- package/api/theme/targets/targets.test.mjs +15 -0
- package/assets/docs/icons.doc.mjs +2 -2
- package/assets/docs/typography.doc.mjs +2 -2
- package/assets/templates/blocks/components/DateTimeInput/DateTimeInputShowcase.tsx +4 -1
- package/assets/templates/blocks/components/Table/TableRowStatusTable.doc.mjs +3 -3
- package/assets/templates/blocks/components/Table/TableRowStatusTable.tsx +4 -4
- package/assets/templates/blocks/components/TimeInput/TimeInputShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/TimeInput/TimeInputShowcase.tsx +12 -6
- package/assets/templates/pages/ai-chat/template.doc.mjs +1 -1
- package/assets/templates/pages/centered-hero/template.doc.mjs +1 -1
- package/assets/templates/pages/contact-form/template.doc.mjs +1 -1
- package/assets/templates/pages/dashboard/template.doc.mjs +1 -1
- package/assets/templates/pages/detail-page/template.doc.mjs +1 -1
- package/assets/templates/pages/gallery-hero/template.doc.mjs +1 -1
- package/assets/templates/pages/incident-console/template.doc.mjs +1 -1
- package/assets/templates/pages/login/template.doc.mjs +1 -1
- package/assets/templates/pages/messaging-shell/template.doc.mjs +1 -1
- package/assets/templates/pages/product-gallery/template.doc.mjs +1 -1
- package/assets/templates/pages/settings/template.doc.mjs +1 -1
- package/assets/templates/pages/table/template.doc.mjs +1 -1
- package/assets/templates/pages/table-filter/page.tsx +897 -365
- package/foundation/discovery/__fixtures__/component-accessibility-overlay.doc.d.mts +16 -1
- package/foundation/discovery/__fixtures__/component-accessibility-overlay.doc.mjs +17 -2
- package/foundation/discovery/__fixtures__/component-anatomy-override.doc.d.mts +38 -0
- package/foundation/discovery/__fixtures__/component-anatomy-override.doc.mjs +45 -0
- package/foundation/discovery/component-loader.mjs +6 -2
- package/foundation/discovery/component-loader.test.mjs +36 -1
- package/foundation/discovery/componentDocOverlay.test.mjs +29 -2
- package/foundation/discovery/theming-targets.d.mts +8 -3
- package/foundation/discovery/theming-targets.mjs +58 -6
- package/foundation/discovery/theming-targets.test.mjs +71 -2
- 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
|
-
//
|
|
1276
|
-
//
|
|
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.
|
|
1351
|
-
//
|
|
1352
|
-
// and
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
|
|
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(
|
|
2262
|
-
endKeys: stickyKeys(
|
|
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="
|
|
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
|
-
<
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
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
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
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
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
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="
|
|
3498
|
+
wrap="nowrap"
|
|
3119
3499
|
minHeight={32}
|
|
3120
3500
|
xstyle={styles.bar}>
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
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
|
-
|
|
3143
|
-
label={saved.name}
|
|
3522
|
+
label="All"
|
|
3144
3523
|
size="sm"
|
|
3145
|
-
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
|
-
|
|
3153
|
-
|
|
3528
|
+
activeSavedViewId == null
|
|
3529
|
+
? styles.filterLabelValue
|
|
3530
|
+
: styles.filterLabelEmpty,
|
|
3531
|
+
activeSavedViewId != null && styles.filterSurface,
|
|
3154
3532
|
]}
|
|
3155
|
-
|
|
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
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
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
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
}
|
|
3879
|
-
|
|
3880
|
-
|
|
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
|
|
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
|
-
<
|
|
4552
|
+
<AdaptiveSavedViewForm
|
|
4000
4553
|
isOpen={creatingName != null}
|
|
4001
4554
|
onOpenChange={open => setCreatingName(open ? '' : null)}
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
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
|
-
<
|
|
4011
|
-
<
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
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
|
-
<
|
|
4579
|
+
<AdaptiveSavedViewForm
|
|
4036
4580
|
isOpen={editing != null}
|
|
4037
4581
|
onOpenChange={open => !open && setEditing(null)}
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
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
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
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="
|
|
4075
|
-
variant="
|
|
4076
|
-
onClick={() => editing &&
|
|
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
|
-
</
|
|
4089
|
-
</
|
|
4090
|
-
</
|
|
4620
|
+
</StackItem>
|
|
4621
|
+
</HStack>
|
|
4622
|
+
</AdaptiveSavedViewForm>
|
|
4091
4623
|
</>
|
|
4092
4624
|
);
|
|
4093
4625
|
}
|