@tribepad/themis 1.5.9 → 1.6.0
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/CHANGELOG.md +14 -0
- package/dist/elements/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/elements/Checkbox/index.js +1 -1
- package/dist/elements/Checkbox/index.js.map +1 -1
- package/dist/elements/Checkbox/index.mjs +1 -1
- package/dist/elements/Checkbox/index.mjs.map +1 -1
- package/dist/elements/Filters/AppliedFilters.d.ts +4 -0
- package/dist/elements/Filters/AppliedFilters.d.ts.map +1 -0
- package/dist/elements/Filters/FilterPopover.d.ts +33 -0
- package/dist/elements/Filters/FilterPopover.d.ts.map +1 -0
- package/dist/elements/Filters/Filters.d.ts +25 -0
- package/dist/elements/Filters/Filters.d.ts.map +1 -0
- package/dist/elements/Filters/Filters.styles.d.ts +24 -0
- package/dist/elements/Filters/Filters.styles.d.ts.map +1 -0
- package/dist/elements/Filters/Filters.types.d.ts +123 -0
- package/dist/elements/Filters/Filters.types.d.ts.map +1 -0
- package/dist/elements/Filters/FiltersContext.d.ts +36 -0
- package/dist/elements/Filters/FiltersContext.d.ts.map +1 -0
- package/dist/elements/Filters/index.css +2 -0
- package/dist/elements/Filters/index.css.map +1 -0
- package/dist/elements/Filters/index.d.ts +26 -0
- package/dist/elements/Filters/index.d.ts.map +1 -0
- package/dist/elements/Filters/index.js +3 -0
- package/dist/elements/Filters/index.js.map +1 -0
- package/dist/elements/Filters/index.mjs +3 -0
- package/dist/elements/Filters/index.mjs.map +1 -0
- package/dist/elements/PasswordField/PasswordField.types.d.ts +34 -34
- package/dist/elements/Table/BulkActionBar.d.ts +5 -0
- package/dist/elements/Table/BulkActionBar.d.ts.map +1 -0
- package/dist/elements/Table/BulkActionBar.styles.d.ts +17 -0
- package/dist/elements/Table/BulkActionBar.styles.d.ts.map +1 -0
- package/dist/elements/Table/BulkActionBar.types.d.ts +136 -0
- package/dist/elements/Table/BulkActionBar.types.d.ts.map +1 -0
- package/dist/elements/Table/Table.d.ts.map +1 -1
- package/dist/elements/Table/Table.styles.d.ts +2 -2
- package/dist/elements/Table/Table.styles.d.ts.map +1 -1
- package/dist/elements/Table/Table.types.d.ts +14 -4
- package/dist/elements/Table/Table.types.d.ts.map +1 -1
- package/dist/elements/Table/TableCardList.d.ts +25 -0
- package/dist/elements/Table/TableCardList.d.ts.map +1 -0
- package/dist/elements/Table/TableCardList.styles.d.ts +15 -0
- package/dist/elements/Table/TableCardList.styles.d.ts.map +1 -0
- package/dist/elements/Table/TableCardList.types.d.ts +48 -0
- package/dist/elements/Table/TableCardList.types.d.ts.map +1 -0
- package/dist/elements/Table/index.d.ts +9 -0
- package/dist/elements/Table/index.d.ts.map +1 -1
- package/dist/elements/Table/index.js +1 -1
- package/dist/elements/Table/index.js.map +1 -1
- package/dist/elements/Table/index.mjs +1 -1
- package/dist/elements/Table/index.mjs.map +1 -1
- package/dist/elements/Table/resolveSelectedCount.d.ts +20 -0
- package/dist/elements/Table/resolveSelectedCount.d.ts.map +1 -0
- package/dist/elements/Table/useBulkActionShortcut.d.ts +29 -0
- package/dist/elements/Table/useBulkActionShortcut.d.ts.map +1 -0
- package/dist/elements/TextField/TextField.types.d.ts +68 -68
- package/dist/elements/index.d.ts +4 -1
- package/dist/elements/index.d.ts.map +1 -1
- package/dist/elements/index.js +1 -1
- package/dist/elements/index.js.map +1 -1
- package/dist/elements/index.mjs +1 -1
- package/dist/elements/index.mjs.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/hooks/index.mjs.map +1 -1
- package/dist/hooks/useContainerNarrow.d.ts +15 -0
- package/dist/hooks/useContainerNarrow.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/styles/defaults.css +15 -0
- package/package.json +6 -1
- package/src/elements/Filters/Filters.stories.tsx +148 -0
- package/src/elements/Table/Table.stories.tsx +467 -10
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @see Storybook (when running)
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { useState, type ReactElement } from 'react';
|
|
12
|
+
import { useState, useRef, type ReactElement } from 'react';
|
|
13
13
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
14
14
|
import {
|
|
15
15
|
Table,
|
|
@@ -18,9 +18,23 @@ import {
|
|
|
18
18
|
Column,
|
|
19
19
|
Row,
|
|
20
20
|
Cell,
|
|
21
|
-
type SortDescriptor,
|
|
22
21
|
} from './Table';
|
|
22
|
+
import { BulkActionBar } from './BulkActionBar';
|
|
23
|
+
import { TableCardList } from './TableCardList';
|
|
24
|
+
import { resolveSelectedCount } from './resolveSelectedCount';
|
|
25
|
+
import { useBulkActionShortcut } from './useBulkActionShortcut';
|
|
26
|
+
import { useContainerNarrow } from '../../hooks/useContainerNarrow';
|
|
27
|
+
import type { Selection, SortDescriptor } from './Table.types';
|
|
28
|
+
import {
|
|
29
|
+
Filters,
|
|
30
|
+
FilterSearch,
|
|
31
|
+
FilterSelect,
|
|
32
|
+
FilterChips,
|
|
33
|
+
type FilterValues,
|
|
34
|
+
} from '../Filters';
|
|
23
35
|
import { Button } from '../Button';
|
|
36
|
+
import { Dropdown } from '../Dropdown';
|
|
37
|
+
import { MoreHorizontal, Mail, ArrowRight, X, Download } from 'lucide-react';
|
|
24
38
|
|
|
25
39
|
// Sample data for all stories
|
|
26
40
|
const sampleFiles = [
|
|
@@ -55,9 +69,13 @@ const meta = {
|
|
|
55
69
|
argTypes: {
|
|
56
70
|
variant: {
|
|
57
71
|
control: 'select',
|
|
58
|
-
options: ['default', 'bordered'
|
|
72
|
+
options: ['default', 'bordered'],
|
|
59
73
|
description: 'Visual variant of the table',
|
|
60
74
|
},
|
|
75
|
+
zebra: {
|
|
76
|
+
control: 'boolean',
|
|
77
|
+
description: 'Alternating row backgrounds (on by default; orthogonal to variant)',
|
|
78
|
+
},
|
|
61
79
|
size: {
|
|
62
80
|
control: 'select',
|
|
63
81
|
options: ['sm', 'default', 'lg'],
|
|
@@ -139,12 +157,37 @@ export const Bordered: Story = {
|
|
|
139
157
|
};
|
|
140
158
|
|
|
141
159
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
160
|
+
* Zebra striping (on by default)
|
|
161
|
+
* Alternating row colours for better readability. Disable with `zebra={false}`.
|
|
162
|
+
*/
|
|
163
|
+
export const Zebra: Story = {
|
|
164
|
+
render: () => (
|
|
165
|
+
<Table aria-label="Files">
|
|
166
|
+
<TableHeader>
|
|
167
|
+
<Column isRowHeader>Name</Column>
|
|
168
|
+
<Column>Type</Column>
|
|
169
|
+
<Column>Size</Column>
|
|
170
|
+
</TableHeader>
|
|
171
|
+
<TableBody items={sampleFiles}>
|
|
172
|
+
{(item) => (
|
|
173
|
+
<Row key={item.id}>
|
|
174
|
+
<Cell>{item.name}</Cell>
|
|
175
|
+
<Cell>{item.type}</Cell>
|
|
176
|
+
<Cell>{item.size}</Cell>
|
|
177
|
+
</Row>
|
|
178
|
+
)}
|
|
179
|
+
</TableBody>
|
|
180
|
+
</Table>
|
|
181
|
+
),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Zebra disabled
|
|
186
|
+
* Flat rows when `zebra={false}` is passed.
|
|
144
187
|
*/
|
|
145
|
-
export const
|
|
188
|
+
export const NoZebra: Story = {
|
|
146
189
|
render: () => (
|
|
147
|
-
<Table aria-label="Files"
|
|
190
|
+
<Table aria-label="Files" zebra={false}>
|
|
148
191
|
<TableHeader>
|
|
149
192
|
<Column isRowHeader>Name</Column>
|
|
150
193
|
<Column>Type</Column>
|
|
@@ -610,7 +653,7 @@ export const WithActionButtons: Story = {
|
|
|
610
653
|
|
|
611
654
|
/**
|
|
612
655
|
* Full Featured Table
|
|
613
|
-
* Demonstrates selection, sorting, and
|
|
656
|
+
* Demonstrates selection, sorting, and zebra striping together
|
|
614
657
|
*/
|
|
615
658
|
export const FullFeatured: Story = {
|
|
616
659
|
render: function FullFeaturedTable(): ReactElement {
|
|
@@ -647,7 +690,6 @@ export const FullFeatured: Story = {
|
|
|
647
690
|
</div>
|
|
648
691
|
<Table
|
|
649
692
|
aria-label="Candidates"
|
|
650
|
-
variant="striped"
|
|
651
693
|
selectionMode="multiple"
|
|
652
694
|
selectedKeys={selectedKeys}
|
|
653
695
|
onSelectionChange={(keys) => setSelectedKeys(keys as 'all' | Set<string>)}
|
|
@@ -728,7 +770,7 @@ export const KeyboardNavigation: Story = {
|
|
|
728
770
|
export const AllVariants: Story = {
|
|
729
771
|
render: () => (
|
|
730
772
|
<div className="space-y-8">
|
|
731
|
-
{(['default', 'bordered'
|
|
773
|
+
{(['default', 'bordered'] as const).map((variant) => (
|
|
732
774
|
<div key={variant} className="space-y-2">
|
|
733
775
|
<h3 className="font-semibold capitalize">{variant} Variant</h3>
|
|
734
776
|
<Table aria-label={`${variant} table`} variant={variant}>
|
|
@@ -788,3 +830,418 @@ export const AllSizes: Story = {
|
|
|
788
830
|
</div>
|
|
789
831
|
),
|
|
790
832
|
};
|
|
833
|
+
|
|
834
|
+
// ============================================================================
|
|
835
|
+
// Recipe: Row actions menu (⋯) — reuse the Themis Dropdown (APG menu button)
|
|
836
|
+
// ============================================================================
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Row actions menu
|
|
840
|
+
*
|
|
841
|
+
* The canonical per-row actions cell: a Themis `Dropdown` (APG menu button —
|
|
842
|
+
* roving tabindex, arrow/Home/End, Esc/Tab close + return focus, auto-flip near
|
|
843
|
+
* the viewport edge, all from React Aria). Themis ships NO dedicated row-actions
|
|
844
|
+
* component — compose the Dropdown instead. Keep the trigger a 44px icon button
|
|
845
|
+
* with an `aria-label` naming the row.
|
|
846
|
+
*/
|
|
847
|
+
export const RowActionsMenu: Story = {
|
|
848
|
+
render: () => (
|
|
849
|
+
<Table aria-label="Files">
|
|
850
|
+
<TableHeader>
|
|
851
|
+
<Column isRowHeader>Name</Column>
|
|
852
|
+
<Column>Type</Column>
|
|
853
|
+
<Column align="end">Actions</Column>
|
|
854
|
+
</TableHeader>
|
|
855
|
+
<TableBody items={sampleFiles}>
|
|
856
|
+
{(item) => (
|
|
857
|
+
<Row key={item.id}>
|
|
858
|
+
<Cell>{item.name}</Cell>
|
|
859
|
+
<Cell>{item.type}</Cell>
|
|
860
|
+
<Cell align="end">
|
|
861
|
+
<Dropdown>
|
|
862
|
+
<Dropdown.Trigger>
|
|
863
|
+
<Button
|
|
864
|
+
variant="ghost"
|
|
865
|
+
visualSize="icon"
|
|
866
|
+
aria-label={`Actions for ${item.name}`}
|
|
867
|
+
>
|
|
868
|
+
<MoreHorizontal aria-hidden="true" />
|
|
869
|
+
</Button>
|
|
870
|
+
</Dropdown.Trigger>
|
|
871
|
+
<Dropdown.Menu align="end" side="bottom">
|
|
872
|
+
<Dropdown.Item onAction={() => {}}>View</Dropdown.Item>
|
|
873
|
+
<Dropdown.Item onAction={() => {}}>Edit</Dropdown.Item>
|
|
874
|
+
<Dropdown.Separator />
|
|
875
|
+
<Dropdown.Item onAction={() => {}}>Delete</Dropdown.Item>
|
|
876
|
+
</Dropdown.Menu>
|
|
877
|
+
</Dropdown>
|
|
878
|
+
</Cell>
|
|
879
|
+
</Row>
|
|
880
|
+
)}
|
|
881
|
+
</TableBody>
|
|
882
|
+
</Table>
|
|
883
|
+
),
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// ============================================================================
|
|
887
|
+
// Recipe: Bulk actions toolbar + selection (+ "A" jump shortcut)
|
|
888
|
+
// ============================================================================
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Bulk actions
|
|
892
|
+
*
|
|
893
|
+
* Multiple selection drives a {@link BulkActionBar} passed to the Table's
|
|
894
|
+
* `toolbar` slot, so it renders inside the card frame above the header. Selection
|
|
895
|
+
* is consumer-owned (`selectedKeys` + `onSelectionChange`); the count is resolved
|
|
896
|
+
* with `resolveSelectedCount` so the `'all'` sentinel is handled. `useBulkActionShortcut`
|
|
897
|
+
* lets the user press **A** — only while focus is within the table — to jump to the
|
|
898
|
+
* toolbar; `showJumpHint` advertises it.
|
|
899
|
+
*/
|
|
900
|
+
export const BulkActions: Story = {
|
|
901
|
+
render: function BulkActionsStory(): ReactElement {
|
|
902
|
+
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set());
|
|
903
|
+
const tableRef = useRef<HTMLTableElement>(null);
|
|
904
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
905
|
+
const allKeys = sampleFiles.map((f) => f.id);
|
|
906
|
+
const selectedCount = resolveSelectedCount(selectedKeys, allKeys);
|
|
907
|
+
|
|
908
|
+
// Shortcut is scoped to the table — pressing "A" elsewhere on the page does nothing.
|
|
909
|
+
useBulkActionShortcut({ scopeRef: tableRef, toolbarRef: barRef });
|
|
910
|
+
|
|
911
|
+
const toolbar = (
|
|
912
|
+
<BulkActionBar
|
|
913
|
+
ref={barRef}
|
|
914
|
+
selectedCount={selectedCount}
|
|
915
|
+
showJumpHint
|
|
916
|
+
actions={[
|
|
917
|
+
{ id: 'email', label: 'Email', icon: <Mail aria-hidden="true" /> },
|
|
918
|
+
{ id: 'move', label: 'Move stage', icon: <ArrowRight aria-hidden="true" /> },
|
|
919
|
+
{ id: 'reject', label: 'Reject', icon: <X aria-hidden="true" /> },
|
|
920
|
+
{ id: 'export', label: 'Export', icon: <Download aria-hidden="true" /> },
|
|
921
|
+
]}
|
|
922
|
+
onAction={() => {}}
|
|
923
|
+
onClearSelection={() => setSelectedKeys(new Set())}
|
|
924
|
+
labels={{
|
|
925
|
+
selectedCount: (n) => `${n} selected`,
|
|
926
|
+
clear: 'Clear',
|
|
927
|
+
toolbarLabel: (n) => `Bulk actions for ${n} selected`,
|
|
928
|
+
actionsAvailable: (n) => `${n} actions available`,
|
|
929
|
+
jumpHint: (key) => <>Press {key} to jump here</>,
|
|
930
|
+
jumpAnnouncement: 'Press A to jump to bulk actions',
|
|
931
|
+
}}
|
|
932
|
+
/>
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
return (
|
|
936
|
+
<Table
|
|
937
|
+
ref={tableRef}
|
|
938
|
+
aria-label="Files"
|
|
939
|
+
toolbar={toolbar}
|
|
940
|
+
selectionMode="multiple"
|
|
941
|
+
selectedKeys={selectedKeys}
|
|
942
|
+
onSelectionChange={setSelectedKeys}
|
|
943
|
+
>
|
|
944
|
+
<TableHeader>
|
|
945
|
+
<Column isRowHeader>Name</Column>
|
|
946
|
+
<Column>Type</Column>
|
|
947
|
+
<Column>Size</Column>
|
|
948
|
+
</TableHeader>
|
|
949
|
+
<TableBody items={sampleFiles}>
|
|
950
|
+
{(item) => (
|
|
951
|
+
<Row key={item.id}>
|
|
952
|
+
<Cell>{item.name}</Cell>
|
|
953
|
+
<Cell>{item.type}</Cell>
|
|
954
|
+
<Cell>{item.size}</Cell>
|
|
955
|
+
</Row>
|
|
956
|
+
)}
|
|
957
|
+
</TableBody>
|
|
958
|
+
</Table>
|
|
959
|
+
);
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
// ============================================================================
|
|
964
|
+
// Recipe: responsive reflow to cards on narrow containers
|
|
965
|
+
// ============================================================================
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Responsive cards
|
|
969
|
+
*
|
|
970
|
+
* `useContainerNarrow` watches the wrapper's width; below the breakpoint the
|
|
971
|
+
* page swaps `<Table>` for `<TableCardList>`. Selection + sort state are shared
|
|
972
|
+
* (consumer-owned), so they persist across the switch.
|
|
973
|
+
*
|
|
974
|
+
* The wrapper is full-width, so **resize the Storybook viewport / canvas** (or
|
|
975
|
+
* the Viewport toolbar) below ~640px and the table reflows to cards. Because it's
|
|
976
|
+
* a *container* query (not a media query), a table placed in a narrow panel cards
|
|
977
|
+
* up too — even on a wide screen.
|
|
978
|
+
*/
|
|
979
|
+
export const ResponsiveCards: Story = {
|
|
980
|
+
parameters: { layout: 'fullscreen' },
|
|
981
|
+
render: function ResponsiveCardsStory(): ReactElement {
|
|
982
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
983
|
+
const narrow = useContainerNarrow(wrapperRef, 640);
|
|
984
|
+
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set());
|
|
985
|
+
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor | undefined>(undefined);
|
|
986
|
+
|
|
987
|
+
const sorted = sortDescriptor
|
|
988
|
+
? [...sampleFiles].sort((a, b) => {
|
|
989
|
+
const col = sortDescriptor.column as 'name' | 'type';
|
|
990
|
+
const cmp = String(a[col]).localeCompare(String(b[col]));
|
|
991
|
+
return sortDescriptor.direction === 'ascending' ? cmp : -cmp;
|
|
992
|
+
})
|
|
993
|
+
: sampleFiles;
|
|
994
|
+
|
|
995
|
+
return (
|
|
996
|
+
<div ref={wrapperRef} style={{ padding: 16 }}>
|
|
997
|
+
{narrow ? (
|
|
998
|
+
<TableCardList
|
|
999
|
+
items={sorted}
|
|
1000
|
+
getRowId={(f) => f.id}
|
|
1001
|
+
aria-label="Files"
|
|
1002
|
+
selectionMode="multiple"
|
|
1003
|
+
selectedKeys={selectedKeys}
|
|
1004
|
+
onSelectionChange={setSelectedKeys}
|
|
1005
|
+
sortDescriptor={sortDescriptor}
|
|
1006
|
+
onSortChange={setSortDescriptor}
|
|
1007
|
+
sortColumns={[
|
|
1008
|
+
{ id: 'name', label: 'Name' },
|
|
1009
|
+
{ id: 'type', label: 'Type' },
|
|
1010
|
+
]}
|
|
1011
|
+
renderEmptyState={() => <span>No files</span>}
|
|
1012
|
+
labels={{
|
|
1013
|
+
results: (n) => `${n} files`,
|
|
1014
|
+
selectAll: 'Select all',
|
|
1015
|
+
sort: 'Sort',
|
|
1016
|
+
sortPlaceholder: 'Sort by…',
|
|
1017
|
+
sortDirection: (d) => `Sort direction: ${d}`,
|
|
1018
|
+
selectItem: (item) => `Select ${(item as { name: string }).name}`,
|
|
1019
|
+
}}
|
|
1020
|
+
renderCard={(f) => (
|
|
1021
|
+
<div className="flex flex-col gap-1">
|
|
1022
|
+
<span className="font-semibold">{f.name}</span>
|
|
1023
|
+
<span className="text-sm text-[var(--menu-muted)]">
|
|
1024
|
+
{f.type} · {f.size}
|
|
1025
|
+
</span>
|
|
1026
|
+
</div>
|
|
1027
|
+
)}
|
|
1028
|
+
/>
|
|
1029
|
+
) : (
|
|
1030
|
+
<Table
|
|
1031
|
+
aria-label="Files"
|
|
1032
|
+
selectionMode="multiple"
|
|
1033
|
+
selectedKeys={selectedKeys}
|
|
1034
|
+
onSelectionChange={setSelectedKeys}
|
|
1035
|
+
sortDescriptor={sortDescriptor}
|
|
1036
|
+
onSortChange={setSortDescriptor}
|
|
1037
|
+
>
|
|
1038
|
+
<TableHeader>
|
|
1039
|
+
<Column id="name" isRowHeader allowsSorting>
|
|
1040
|
+
Name
|
|
1041
|
+
</Column>
|
|
1042
|
+
<Column id="type" allowsSorting>
|
|
1043
|
+
Type
|
|
1044
|
+
</Column>
|
|
1045
|
+
<Column>Size</Column>
|
|
1046
|
+
</TableHeader>
|
|
1047
|
+
<TableBody items={sorted}>
|
|
1048
|
+
{(item) => (
|
|
1049
|
+
<Row key={item.id}>
|
|
1050
|
+
<Cell>{item.name}</Cell>
|
|
1051
|
+
<Cell>{item.type}</Cell>
|
|
1052
|
+
<Cell>{item.size}</Cell>
|
|
1053
|
+
</Row>
|
|
1054
|
+
)}
|
|
1055
|
+
</TableBody>
|
|
1056
|
+
</Table>
|
|
1057
|
+
)}
|
|
1058
|
+
</div>
|
|
1059
|
+
);
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
// ============================================================================
|
|
1064
|
+
// Recipe: full mobile list page (Filters bottom-sheet + table → cards)
|
|
1065
|
+
// ============================================================================
|
|
1066
|
+
|
|
1067
|
+
const candidateStages = [
|
|
1068
|
+
{ value: 'Interview', label: 'Interview' },
|
|
1069
|
+
{ value: 'Screening', label: 'Screening' },
|
|
1070
|
+
{ value: 'Offer', label: 'Offer' },
|
|
1071
|
+
{ value: 'Hired', label: 'Hired' },
|
|
1072
|
+
{ value: 'Rejected', label: 'Rejected' },
|
|
1073
|
+
];
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Mobile list page
|
|
1077
|
+
*
|
|
1078
|
+
* The complete responsive pattern: a single `useContainerNarrow` drives BOTH the
|
|
1079
|
+
* Filters bar (collapses to a bottom-sheet) AND the table (reflows to a card list)
|
|
1080
|
+
* so they switch together. Resize the Storybook canvas/viewport below ~700px to
|
|
1081
|
+
* see the mobile experience; widen it for the desktop table + inline filters.
|
|
1082
|
+
*/
|
|
1083
|
+
export const MobileListPage: Story = {
|
|
1084
|
+
parameters: { layout: 'fullscreen' },
|
|
1085
|
+
render: function MobileListPageStory(): ReactElement {
|
|
1086
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
1087
|
+
const narrow = useContainerNarrow(ref, 700);
|
|
1088
|
+
const [values, setValues] = useState<FilterValues>({});
|
|
1089
|
+
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set());
|
|
1090
|
+
|
|
1091
|
+
const q = typeof values.q === 'string' ? values.q.toLowerCase() : '';
|
|
1092
|
+
const stage = typeof values.stage === 'string' ? values.stage : '';
|
|
1093
|
+
const rows = candidatesData.filter(
|
|
1094
|
+
(c) =>
|
|
1095
|
+
(!q || c.name.toLowerCase().includes(q) || c.role.toLowerCase().includes(q)) &&
|
|
1096
|
+
(!stage || c.status === stage)
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
return (
|
|
1100
|
+
<div ref={ref} style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
1101
|
+
<Filters
|
|
1102
|
+
values={values}
|
|
1103
|
+
onChange={setValues}
|
|
1104
|
+
narrow={narrow}
|
|
1105
|
+
aria-label="Filter candidates"
|
|
1106
|
+
labels={{
|
|
1107
|
+
done: 'Done',
|
|
1108
|
+
clear: 'Clear',
|
|
1109
|
+
clearAll: 'Clear all',
|
|
1110
|
+
close: 'Close',
|
|
1111
|
+
filtersButton: 'Filters',
|
|
1112
|
+
}}
|
|
1113
|
+
>
|
|
1114
|
+
<FilterSearch name="q" label="Search candidates" placeholder="Search name or role…" />
|
|
1115
|
+
<FilterSelect name="stage" label="Stage" options={candidateStages} />
|
|
1116
|
+
<FilterChips clearAllLabel="Clear all" removeChipLabel={(label) => `Remove ${label}`} />
|
|
1117
|
+
</Filters>
|
|
1118
|
+
|
|
1119
|
+
{narrow ? (
|
|
1120
|
+
<TableCardList
|
|
1121
|
+
items={rows}
|
|
1122
|
+
getRowId={(c) => c.id}
|
|
1123
|
+
aria-label="Candidates"
|
|
1124
|
+
selectionMode="multiple"
|
|
1125
|
+
selectedKeys={selectedKeys}
|
|
1126
|
+
onSelectionChange={setSelectedKeys}
|
|
1127
|
+
renderEmptyState={() => <span>No matching candidates</span>}
|
|
1128
|
+
labels={{
|
|
1129
|
+
results: (n) => `${n} candidates`,
|
|
1130
|
+
selectAll: 'Select all',
|
|
1131
|
+
sort: 'Sort',
|
|
1132
|
+
sortDirection: (d) => `Sort direction: ${d}`,
|
|
1133
|
+
selectItem: (item) => `Select ${(item as { name: string }).name}`,
|
|
1134
|
+
}}
|
|
1135
|
+
renderCard={(c) => (
|
|
1136
|
+
<div className="flex flex-col gap-1">
|
|
1137
|
+
<span className="font-semibold">{c.name}</span>
|
|
1138
|
+
<span className="text-sm text-[var(--menu-muted)]">{c.role}</span>
|
|
1139
|
+
<span className="text-sm">
|
|
1140
|
+
{c.status} · Match {c.score}
|
|
1141
|
+
</span>
|
|
1142
|
+
</div>
|
|
1143
|
+
)}
|
|
1144
|
+
/>
|
|
1145
|
+
) : (
|
|
1146
|
+
<Table
|
|
1147
|
+
aria-label="Candidates"
|
|
1148
|
+
selectionMode="multiple"
|
|
1149
|
+
selectedKeys={selectedKeys}
|
|
1150
|
+
onSelectionChange={setSelectedKeys}
|
|
1151
|
+
>
|
|
1152
|
+
<TableHeader>
|
|
1153
|
+
<Column isRowHeader>Candidate</Column>
|
|
1154
|
+
<Column>Role</Column>
|
|
1155
|
+
<Column>Stage</Column>
|
|
1156
|
+
<Column align="end">Match</Column>
|
|
1157
|
+
</TableHeader>
|
|
1158
|
+
<TableBody items={rows} renderEmptyState={() => <span>No matching candidates</span>}>
|
|
1159
|
+
{(item) => (
|
|
1160
|
+
<Row key={item.id}>
|
|
1161
|
+
<Cell>{item.name}</Cell>
|
|
1162
|
+
<Cell>{item.role}</Cell>
|
|
1163
|
+
<Cell>{item.status}</Cell>
|
|
1164
|
+
<Cell align="end">{item.score}</Cell>
|
|
1165
|
+
</Row>
|
|
1166
|
+
)}
|
|
1167
|
+
</TableBody>
|
|
1168
|
+
</Table>
|
|
1169
|
+
)}
|
|
1170
|
+
</div>
|
|
1171
|
+
);
|
|
1172
|
+
},
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Mobile bulk actions
|
|
1177
|
+
*
|
|
1178
|
+
* The card list with selection + the `BulkActionBar` above it (shown in a phone-
|
|
1179
|
+
* width frame). Selection is consumer-owned; the bar appears once a card is
|
|
1180
|
+
* selected, announces the count, and Clear resets. (The "A" jump shortcut is
|
|
1181
|
+
* desktop-only, so the hint is omitted here.)
|
|
1182
|
+
*/
|
|
1183
|
+
export const MobileBulkActions: Story = {
|
|
1184
|
+
parameters: { layout: 'fullscreen' },
|
|
1185
|
+
render: function MobileBulkActionsStory(): ReactElement {
|
|
1186
|
+
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set(['1', '3']));
|
|
1187
|
+
const allKeys = candidatesData.map((c) => c.id);
|
|
1188
|
+
const selectedCount = resolveSelectedCount(selectedKeys, allKeys);
|
|
1189
|
+
|
|
1190
|
+
return (
|
|
1191
|
+
<div
|
|
1192
|
+
style={{
|
|
1193
|
+
maxWidth: 390,
|
|
1194
|
+
margin: '0 auto',
|
|
1195
|
+
padding: 16,
|
|
1196
|
+
display: 'flex',
|
|
1197
|
+
flexDirection: 'column',
|
|
1198
|
+
gap: 12,
|
|
1199
|
+
}}
|
|
1200
|
+
>
|
|
1201
|
+
<BulkActionBar
|
|
1202
|
+
selectedCount={selectedCount}
|
|
1203
|
+
framed
|
|
1204
|
+
sticky={false}
|
|
1205
|
+
actions={[
|
|
1206
|
+
{ id: 'email', label: 'Email', icon: <Mail aria-hidden="true" /> },
|
|
1207
|
+
{ id: 'move', label: 'Move stage', icon: <ArrowRight aria-hidden="true" /> },
|
|
1208
|
+
{ id: 'reject', label: 'Reject', icon: <X aria-hidden="true" /> },
|
|
1209
|
+
{ id: 'export', label: 'Export', icon: <Download aria-hidden="true" /> },
|
|
1210
|
+
]}
|
|
1211
|
+
onAction={() => {}}
|
|
1212
|
+
onClearSelection={() => setSelectedKeys(new Set())}
|
|
1213
|
+
labels={{
|
|
1214
|
+
selectedCount: (n) => `${n} selected`,
|
|
1215
|
+
clear: 'Clear',
|
|
1216
|
+
toolbarLabel: (n) => `Bulk actions for ${n} selected`,
|
|
1217
|
+
actionsAvailable: (n) => `${n} actions available`,
|
|
1218
|
+
}}
|
|
1219
|
+
/>
|
|
1220
|
+
<TableCardList
|
|
1221
|
+
items={candidatesData}
|
|
1222
|
+
getRowId={(c) => c.id}
|
|
1223
|
+
aria-label="Candidates"
|
|
1224
|
+
selectionMode="multiple"
|
|
1225
|
+
selectedKeys={selectedKeys}
|
|
1226
|
+
onSelectionChange={setSelectedKeys}
|
|
1227
|
+
labels={{
|
|
1228
|
+
results: (n) => `${n} candidates`,
|
|
1229
|
+
selectAll: 'Select all',
|
|
1230
|
+
sort: 'Sort',
|
|
1231
|
+
sortDirection: (d) => `Sort direction: ${d}`,
|
|
1232
|
+
selectItem: (item) => `Select ${(item as { name: string }).name}`,
|
|
1233
|
+
}}
|
|
1234
|
+
renderCard={(c) => (
|
|
1235
|
+
<div className="flex flex-col gap-1">
|
|
1236
|
+
<span className="font-semibold">{c.name}</span>
|
|
1237
|
+
<span className="text-sm text-[var(--menu-muted)]">{c.role}</span>
|
|
1238
|
+
<span className="text-sm">
|
|
1239
|
+
{c.status} · Match {c.score}
|
|
1240
|
+
</span>
|
|
1241
|
+
</div>
|
|
1242
|
+
)}
|
|
1243
|
+
/>
|
|
1244
|
+
</div>
|
|
1245
|
+
);
|
|
1246
|
+
},
|
|
1247
|
+
};
|