@tribepad/themis 1.5.9 → 1.6.1

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 (83) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/elements/Checkbox/Checkbox.d.ts.map +1 -1
  3. package/dist/elements/Checkbox/index.js +1 -1
  4. package/dist/elements/Checkbox/index.js.map +1 -1
  5. package/dist/elements/Checkbox/index.mjs +1 -1
  6. package/dist/elements/Checkbox/index.mjs.map +1 -1
  7. package/dist/elements/Filters/AppliedFilters.d.ts +4 -0
  8. package/dist/elements/Filters/AppliedFilters.d.ts.map +1 -0
  9. package/dist/elements/Filters/FilterPopover.d.ts +33 -0
  10. package/dist/elements/Filters/FilterPopover.d.ts.map +1 -0
  11. package/dist/elements/Filters/Filters.d.ts +25 -0
  12. package/dist/elements/Filters/Filters.d.ts.map +1 -0
  13. package/dist/elements/Filters/Filters.styles.d.ts +24 -0
  14. package/dist/elements/Filters/Filters.styles.d.ts.map +1 -0
  15. package/dist/elements/Filters/Filters.types.d.ts +123 -0
  16. package/dist/elements/Filters/Filters.types.d.ts.map +1 -0
  17. package/dist/elements/Filters/FiltersContext.d.ts +36 -0
  18. package/dist/elements/Filters/FiltersContext.d.ts.map +1 -0
  19. package/dist/elements/Filters/index.css +2 -0
  20. package/dist/elements/Filters/index.css.map +1 -0
  21. package/dist/elements/Filters/index.d.ts +26 -0
  22. package/dist/elements/Filters/index.d.ts.map +1 -0
  23. package/dist/elements/Filters/index.js +3 -0
  24. package/dist/elements/Filters/index.js.map +1 -0
  25. package/dist/elements/Filters/index.mjs +3 -0
  26. package/dist/elements/Filters/index.mjs.map +1 -0
  27. package/dist/elements/PasswordField/PasswordField.types.d.ts +34 -34
  28. package/dist/elements/Sidebar/index.js +1 -1
  29. package/dist/elements/Sidebar/index.js.map +1 -1
  30. package/dist/elements/Sidebar/index.mjs +1 -1
  31. package/dist/elements/Sidebar/index.mjs.map +1 -1
  32. package/dist/elements/Table/BulkActionBar.d.ts +5 -0
  33. package/dist/elements/Table/BulkActionBar.d.ts.map +1 -0
  34. package/dist/elements/Table/BulkActionBar.styles.d.ts +17 -0
  35. package/dist/elements/Table/BulkActionBar.styles.d.ts.map +1 -0
  36. package/dist/elements/Table/BulkActionBar.types.d.ts +136 -0
  37. package/dist/elements/Table/BulkActionBar.types.d.ts.map +1 -0
  38. package/dist/elements/Table/Table.d.ts +4 -5
  39. package/dist/elements/Table/Table.d.ts.map +1 -1
  40. package/dist/elements/Table/Table.styles.d.ts +2 -3
  41. package/dist/elements/Table/Table.styles.d.ts.map +1 -1
  42. package/dist/elements/Table/Table.types.d.ts +14 -30
  43. package/dist/elements/Table/Table.types.d.ts.map +1 -1
  44. package/dist/elements/Table/TableCardList.d.ts +25 -0
  45. package/dist/elements/Table/TableCardList.d.ts.map +1 -0
  46. package/dist/elements/Table/TableCardList.styles.d.ts +15 -0
  47. package/dist/elements/Table/TableCardList.styles.d.ts.map +1 -0
  48. package/dist/elements/Table/TableCardList.types.d.ts +48 -0
  49. package/dist/elements/Table/TableCardList.types.d.ts.map +1 -0
  50. package/dist/elements/Table/index.d.ts +12 -3
  51. package/dist/elements/Table/index.d.ts.map +1 -1
  52. package/dist/elements/Table/index.js +1 -1
  53. package/dist/elements/Table/index.js.map +1 -1
  54. package/dist/elements/Table/index.mjs +1 -1
  55. package/dist/elements/Table/index.mjs.map +1 -1
  56. package/dist/elements/Table/resolveSelectedCount.d.ts +20 -0
  57. package/dist/elements/Table/resolveSelectedCount.d.ts.map +1 -0
  58. package/dist/elements/Table/useBulkActionShortcut.d.ts +29 -0
  59. package/dist/elements/Table/useBulkActionShortcut.d.ts.map +1 -0
  60. package/dist/elements/TextField/TextField.types.d.ts +68 -68
  61. package/dist/elements/index.d.ts +4 -1
  62. package/dist/elements/index.d.ts.map +1 -1
  63. package/dist/elements/index.js +1 -1
  64. package/dist/elements/index.js.map +1 -1
  65. package/dist/elements/index.mjs +1 -1
  66. package/dist/elements/index.mjs.map +1 -1
  67. package/dist/hooks/index.d.ts +1 -0
  68. package/dist/hooks/index.d.ts.map +1 -1
  69. package/dist/hooks/index.js +1 -1
  70. package/dist/hooks/index.js.map +1 -1
  71. package/dist/hooks/index.mjs +1 -1
  72. package/dist/hooks/index.mjs.map +1 -1
  73. package/dist/hooks/useContainerNarrow.d.ts +15 -0
  74. package/dist/hooks/useContainerNarrow.d.ts.map +1 -0
  75. package/dist/index.js +2 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/index.mjs +2 -2
  78. package/dist/index.mjs.map +1 -1
  79. package/dist/styles/defaults.css +15 -0
  80. package/package.json +6 -1
  81. package/src/elements/Filters/Filters.stories.tsx +148 -0
  82. package/src/elements/Sidebar/Sidebar.stories.tsx +1 -1
  83. 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', 'striped'],
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
- * Striped Variant
143
- * Table with alternating row colors for better readability
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 Striped: Story = {
188
+ export const NoZebra: Story = {
146
189
  render: () => (
147
- <Table aria-label="Files" variant="striped">
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 striped variant together
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', 'striped'] as const).map((variant) => (
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
+ };