@charcuterie/ui 0.1.1 → 0.2.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.
Files changed (105) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +69 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +73 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  15. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  16. package/dist/FileDropZone/FileDropZone.js +122 -0
  17. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  18. package/dist/LogViewer/LogViewer.d.ts +88 -0
  19. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  20. package/dist/LogViewer/LogViewer.js +104 -0
  21. package/dist/LogViewer/LogViewer.js.map +1 -0
  22. package/dist/Menu/Menu.d.ts +81 -0
  23. package/dist/Menu/Menu.d.ts.map +1 -0
  24. package/dist/Menu/Menu.js +199 -0
  25. package/dist/Menu/Menu.js.map +1 -0
  26. package/dist/Menu/MenuAction.d.ts +24 -0
  27. package/dist/Menu/MenuAction.d.ts.map +1 -0
  28. package/dist/Menu/MenuAction.js +37 -0
  29. package/dist/Menu/MenuAction.js.map +1 -0
  30. package/dist/Select/Select.d.ts +87 -0
  31. package/dist/Select/Select.d.ts.map +1 -0
  32. package/dist/Select/Select.js +51 -0
  33. package/dist/Select/Select.js.map +1 -0
  34. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  36. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  38. package/dist/Toast/Toast.d.ts +54 -0
  39. package/dist/Toast/Toast.d.ts.map +1 -0
  40. package/dist/Toast/Toast.js +121 -0
  41. package/dist/Toast/Toast.js.map +1 -0
  42. package/dist/Toast/ToastRegion.d.ts +57 -0
  43. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  44. package/dist/Toast/ToastRegion.js +41 -0
  45. package/dist/Toast/ToastRegion.js.map +1 -0
  46. package/dist/Toast/toastLifecycle.d.ts +34 -0
  47. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  48. package/dist/Toast/toastLifecycle.js +25 -0
  49. package/dist/Toast/toastLifecycle.js.map +1 -0
  50. package/dist/Tooltip/Tooltip.d.ts +68 -0
  51. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  52. package/dist/Tooltip/Tooltip.js +104 -0
  53. package/dist/Tooltip/Tooltip.js.map +1 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/mountStory.testHelpers.d.ts +0 -11
  59. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  60. package/dist/mountStory.testHelpers.js +27 -0
  61. package/dist/mountStory.testHelpers.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/Accordion/Accordion.mdx +83 -0
  64. package/src/Accordion/Accordion.stories.tsx +146 -0
  65. package/src/Accordion/Accordion.test.tsx +163 -0
  66. package/src/Accordion/Accordion.tsx +182 -0
  67. package/src/Accordion/AccordionSection.tsx +163 -0
  68. package/src/Field/Field.mdx +74 -0
  69. package/src/Field/Field.stories.tsx +144 -0
  70. package/src/Field/Field.test.tsx +142 -0
  71. package/src/Field/Field.tsx +163 -0
  72. package/src/FileDropZone/FileDropZone.mdx +78 -0
  73. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  74. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  75. package/src/FileDropZone/FileDropZone.tsx +234 -0
  76. package/src/LogViewer/LogViewer.mdx +88 -0
  77. package/src/LogViewer/LogViewer.stories.tsx +135 -0
  78. package/src/LogViewer/LogViewer.test.tsx +173 -0
  79. package/src/LogViewer/LogViewer.tsx +195 -0
  80. package/src/Menu/Menu.mdx +103 -0
  81. package/src/Menu/Menu.stories.tsx +161 -0
  82. package/src/Menu/Menu.test.tsx +202 -0
  83. package/src/Menu/Menu.tsx +300 -0
  84. package/src/Menu/MenuAction.tsx +95 -0
  85. package/src/Select/Select.mdx +74 -0
  86. package/src/Select/Select.stories.tsx +126 -0
  87. package/src/Select/Select.test.tsx +129 -0
  88. package/src/Select/Select.tsx +200 -0
  89. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  90. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  91. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  92. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  93. package/src/Toast/Toast.mdx +84 -0
  94. package/src/Toast/Toast.stories.tsx +192 -0
  95. package/src/Toast/Toast.test.tsx +217 -0
  96. package/src/Toast/Toast.tsx +224 -0
  97. package/src/Toast/ToastRegion.tsx +98 -0
  98. package/src/Toast/toastLifecycle.ts +45 -0
  99. package/src/Tooltip/Tooltip.mdx +69 -0
  100. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  101. package/src/Tooltip/Tooltip.test.tsx +138 -0
  102. package/src/Tooltip/Tooltip.tsx +169 -0
  103. package/src/index.ts +43 -0
  104. package/src/mountStory.testHelpers.ts +30 -0
  105. package/src/sourceRules.test.ts +221 -30
@@ -0,0 +1,95 @@
1
+ import type { ReactNode } from "react"
2
+ import { useEffect } from "react"
3
+
4
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import type { MenuItem } from "./Menu.tsx"
7
+
8
+ export type MenuActionProps = {
9
+ item: MenuItem
10
+ onDismiss: () => void
11
+ /** `RovingFocus.register` — membership of the arrow-key group. */
12
+ register: (value: string) => () => void
13
+ tabIndex: number
14
+ trackElement: (
15
+ key: string,
16
+ element: HTMLButtonElement | null,
17
+ ) => void
18
+ }
19
+
20
+ /**
21
+ * Its own file because **registration is an effect**, and an effect
22
+ * cannot run in a loop — the same reason `TabTrigger` and
23
+ * `AccordionSection` are separate. Any member of a registering group
24
+ * ends up shaped like this.
25
+ *
26
+ * A disabled item is simply not registered, so the arrow keys skip
27
+ * it without any command in `RovingFocus` knowing what "disabled"
28
+ * means. It stays in the DOM and stays announced, which is the
29
+ * difference between "you cannot do this right now" and "this does
30
+ * not exist".
31
+ */
32
+ export const MenuAction = ({
33
+ item,
34
+ onDismiss,
35
+ register,
36
+ tabIndex,
37
+ trackElement,
38
+ }: MenuActionProps): ReactNode => {
39
+ const {
40
+ icon,
41
+ isDisabled = false,
42
+ key,
43
+ label,
44
+ onSelect,
45
+ } = item
46
+
47
+ useEffect(() => {
48
+ if (isDisabled) {
49
+ return
50
+ }
51
+
52
+ return register(key)
53
+ }, [isDisabled, key, register])
54
+
55
+ return (
56
+ <button
57
+ className={toClassName(
58
+ "flex w-full cursor-pointer items-center gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
59
+ "hover:bg-intent-neutral-surface",
60
+ isDisabled &&
61
+ "cursor-not-allowed text-content-disabled hover:bg-transparent",
62
+ FOCUS_RING_CLASS,
63
+ )}
64
+ disabled={isDisabled}
65
+ onClick={() => {
66
+ onSelect()
67
+
68
+ // Choosing dismisses. A menu that stays open after an action
69
+ // has fired is the one interaction users read as "it didn't
70
+ // work", and every one of the fleet's menus closes here
71
+ // today by unmounting its whole parent.
72
+ onDismiss()
73
+ }}
74
+ ref={(element) => {
75
+ trackElement(key, element)
76
+ }}
77
+ role="menuitem"
78
+ tabIndex={tabIndex}
79
+ type="button"
80
+ >
81
+ {icon === undefined ? null : (
82
+ <span
83
+ // Decoration beside the item's own text. An icon that
84
+ // announces itself turns "Delete" into "trash Delete".
85
+ aria-hidden="true"
86
+ className="shrink-0 text-content-secondary"
87
+ >
88
+ {icon}
89
+ </span>
90
+ )}
91
+
92
+ {label}
93
+ </button>
94
+ )
95
+ }
@@ -0,0 +1,74 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as SelectStories from "./Select.stories";
4
+
5
+ <Meta of={SelectStories} />
6
+
7
+ # Select
8
+
9
+ Eighteen sites in the fleet, and they split cleanly in two: **twelve native `<select>`s**
10
+ that differ only in their Tailwind classes, and **six hand-rolled `role="listbox"`
11
+ controls**. This component is the first twelve. The other six are a different pattern —
12
+ see below.
13
+
14
+ <Canvas of={SelectStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## There is no state kind here, and that is the finding
21
+
22
+ Every other interactive component in this package owns its state through
23
+ `@charcuterie/logic`. This one owns **none**, because the platform already does — a
24
+ `<select>` holds the chosen option in the DOM, and putting a `useSinglePicker` beside it
25
+ would create the exact conflict the state layer exists to prevent:
26
+
27
+ > Two things believing they hold `value`.
28
+
29
+ That is the Radix argument from `Popover`, pointed at ourselves. So `value` seeds
30
+ `defaultValue` and changes are reported outward.
31
+
32
+ The consequence worth knowing: **`Select` is uncontrolled and `Tabs` is not**, and a reader
33
+ should be able to tell why without asking. A tab bar's selection has no DOM representation,
34
+ so something has to hold it. A select's does.
35
+
36
+ <Canvas of={SelectStories.AllVariants} />
37
+
38
+ ## Native, and what that rules out
39
+
40
+ | Native gives you | A hand-rolled listbox rebuilds |
41
+ | --- | --- |
42
+ | Type-ahead | a keystroke buffer and a timer |
43
+ | Home / End / PageUp / PageDown | four more key handlers |
44
+ | The mobile wheel picker | nothing that feels native |
45
+ | Form submission and `:invalid` | a hidden input |
46
+ | Autofill | — |
47
+
48
+ What it cannot do is render a rich option — an icon, two lines, a badge — or filter.
49
+ mux-magic has six controls that need one of those (`CommandPicker`, `PathPicker`,
50
+ `LinkPicker`, `EnumPicker`, `AssFieldPicker`, `RenameTargetPicker`), and **every one of them
51
+ is a `Combobox`**: a text input that filters a listbox. Different pattern, different ARIA
52
+ contract, P2 in the plan.
53
+
54
+ Building a bare `Listbox` here that none of the six could adopt would have shipped a
55
+ component with no caller — the mistake this milestone was explicitly checking for.
56
+
57
+ ## The placeholder is a disabled option
58
+
59
+ Not an empty-valued one. An enabled `<option value="">` looks identical, is selectable, and
60
+ silently posts `""`. Disabled, it reads as a prompt and can never be submitted.
61
+
62
+ <Canvas of={SelectStories.AllStates} />
63
+
64
+ ## `appearance-none` takes the arrow away
65
+
66
+ Removing the UA's own chevron and forgetting to put one back leaves a control that looks
67
+ exactly like a text input. The replacement is `aria-hidden` decoration in the same grid
68
+ cell, with `pe-9` reserving room for it.
69
+
70
+ ## Naming, once
71
+
72
+ Either `label` (which becomes `aria-label`) **or** a surrounding `Field` — never both. Two
73
+ naming mechanisms firing at once means the accessible name is whichever won, which is not a
74
+ thing a reader can predict.
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ } from "../board.storyHelpers.tsx"
8
+ import { Field } from "../Field/Field.tsx"
9
+ import type { SelectItem } from "./Select.tsx"
10
+ import { Select } from "./Select.tsx"
11
+
12
+ const PROFILES: SelectItem[] = [
13
+ { label: "Lossless remux", value: "lossless" },
14
+ { label: "Compressed 1080p", value: "compressed" },
15
+ { isDisabled: true, label: "Dolby Vision", value: "dv" },
16
+ ]
17
+
18
+ const GROUPED: SelectItem[] = [
19
+ {
20
+ label: "Video",
21
+ options: [
22
+ { label: "Remux", value: "remux" },
23
+ { label: "Transcode", value: "transcode" },
24
+ ],
25
+ },
26
+ {
27
+ label: "Audio",
28
+ options: [
29
+ { label: "Passthrough", value: "passthrough" },
30
+ { label: "Downmix", value: "downmix" },
31
+ ],
32
+ },
33
+ ]
34
+
35
+ const meta = {
36
+ title: "Components/Select",
37
+ component: Select,
38
+ parameters: { layout: "padded" },
39
+ argTypes: { size: controlSizeArgType },
40
+ args: {
41
+ isDisabled: false,
42
+ options: PROFILES,
43
+ size: "md",
44
+ },
45
+ } satisfies Meta<typeof Select>
46
+
47
+ export default meta
48
+
49
+ type Story = StoryObj<typeof meta>
50
+
51
+ export const Default: Story = {
52
+ args: { label: "Rip profile", options: PROFILES },
53
+ }
54
+
55
+ export const AllVariants: Story = {
56
+ args: { label: "Rip profile", options: PROFILES },
57
+ render: () => (
58
+ <StoryGrid columns={2}>
59
+ <StoryCell label="sm">
60
+ <Select
61
+ label="Profile sm"
62
+ options={PROFILES}
63
+ size="sm"
64
+ />
65
+ </StoryCell>
66
+
67
+ <StoryCell label="lg">
68
+ <Select
69
+ label="Profile lg"
70
+ options={PROFILES}
71
+ size="lg"
72
+ />
73
+ </StoryCell>
74
+
75
+ <StoryCell label="grouped">
76
+ <Select label="Stream kind" options={GROUPED} />
77
+ </StoryCell>
78
+
79
+ <StoryCell label="placeholder">
80
+ <Select
81
+ label="Profile with prompt"
82
+ options={PROFILES}
83
+ placeholder="Choose a profile…"
84
+ />
85
+ </StoryCell>
86
+ </StoryGrid>
87
+ ),
88
+ }
89
+
90
+ /**
91
+ * The disabled option is still announced and still unselectable —
92
+ * "you cannot do this right now", rather than the option not
93
+ * existing. A `placeholder` is the same trick at the top of the
94
+ * list: disabled, so it reads as a prompt and can never be
95
+ * submitted.
96
+ */
97
+ export const AllStates: Story = {
98
+ args: { label: "Rip profile", options: PROFILES },
99
+ render: () => (
100
+ <StoryGrid columns={3}>
101
+ <StoryCell label="default">
102
+ <Select
103
+ label="Profile default"
104
+ options={PROFILES}
105
+ />
106
+ </StoryCell>
107
+
108
+ <StoryCell label="disabled">
109
+ <Select
110
+ isDisabled
111
+ label="Profile disabled"
112
+ options={PROFILES}
113
+ />
114
+ </StoryCell>
115
+
116
+ <StoryCell label="in a Field">
117
+ <Field
118
+ description="Applied to every title on the disc."
119
+ label="Profile in a field"
120
+ >
121
+ <Select options={PROFILES} />
122
+ </Field>
123
+ </StoryCell>
124
+ </StoryGrid>
125
+ ),
126
+ }
@@ -0,0 +1,129 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Select.stories.tsx"
9
+
10
+ const { AllStates, AllVariants, Default } =
11
+ composeStories(stories)
12
+
13
+ test("a select is named, and therefore findable", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ // Twelve of the fleet's fourteen native selects have neither a
18
+ // `<label for>` nor an `aria-label`, so this query returns nothing
19
+ // for any of them. A `<select>` reports `role="combobox"`.
20
+ expectAgentDrivable(canvas, {
21
+ name: "Rip profile",
22
+ role: "combobox",
23
+ })
24
+
25
+ await expectNoAxeViolations(canvasElement)
26
+ })
27
+
28
+ test("choosing reports the value outward", async () => {
29
+ const { canvas } = await mountStory(Default)
30
+
31
+ const select = expectAgentDrivable(canvas, {
32
+ name: "Rip profile",
33
+ role: "combobox",
34
+ }) as HTMLSelectElement
35
+
36
+ await userEvent.selectOptions(select, "compressed")
37
+
38
+ await expect(select.value).toBe("compressed")
39
+ })
40
+
41
+ /**
42
+ * The platform owns the value, and this component owns none. Every
43
+ * other interactive component here holds its state in
44
+ * `@charcuterie/logic`; putting a `useSinglePicker` beside a
45
+ * `<select>` would be two things believing they hold one fact —
46
+ * the Radix argument from `Popover`, pointed at ourselves.
47
+ */
48
+ test("the initial value seeds the DOM and nothing more", async () => {
49
+ const { canvas } = await mountStory(AllVariants)
50
+
51
+ const select = expectAgentDrivable(canvas, {
52
+ name: "Profile with prompt",
53
+ role: "combobox",
54
+ }) as HTMLSelectElement
55
+
56
+ // A placeholder is a *disabled* option, so it shows and cannot be
57
+ // submitted. An empty-valued enabled option would look identical
58
+ // and silently post "".
59
+ await expect(select.value).toBe("")
60
+
61
+ const [placeholder] = Array.from(select.options)
62
+
63
+ await expect(placeholder).toBeDisabled()
64
+ })
65
+
66
+ test("a disabled option stays announced", async () => {
67
+ const { canvas } = await mountStory(Default)
68
+
69
+ const select = expectAgentDrivable(canvas, {
70
+ name: "Rip profile",
71
+ role: "combobox",
72
+ }) as HTMLSelectElement
73
+
74
+ const dolby = Array.from(select.options).find(
75
+ (option) => option.value === "dv",
76
+ )
77
+
78
+ // In the tree, and unselectable. Removing it instead would tell a
79
+ // screen-reader user the profile does not exist rather than that
80
+ // it is unavailable.
81
+ await expect(dolby).toBeDisabled()
82
+
83
+ await expect(dolby).toHaveTextContent("Dolby Vision")
84
+ })
85
+
86
+ test("option groups keep their group names", async () => {
87
+ const { canvas } = await mountStory(AllVariants)
88
+
89
+ const select = expectAgentDrivable(canvas, {
90
+ name: "Stream kind",
91
+ role: "combobox",
92
+ }) as HTMLSelectElement
93
+
94
+ const groups = Array.from(
95
+ select.querySelectorAll("optgroup"),
96
+ ).map((group) => group.label)
97
+
98
+ await expect(groups).toEqual(["Video", "Audio"])
99
+ })
100
+
101
+ test("a disabled select is disabled, not merely dimmed", async () => {
102
+ const { canvas, canvasElement } =
103
+ await mountStory(AllStates)
104
+
105
+ await expect(
106
+ expectAgentDrivable(canvas, {
107
+ name: "Profile disabled",
108
+ role: "combobox",
109
+ }),
110
+ ).toBeDisabled()
111
+
112
+ await expectNoAxeViolations(canvasElement)
113
+ })
114
+
115
+ test("inside a Field, the label does the naming", async () => {
116
+ const { canvas } = await mountStory(AllStates)
117
+
118
+ // No `label` prop on this one — `Field` clones an `id` in and its
119
+ // `<label for>` picks it up. Two naming mechanisms that must not
120
+ // both fire, or the accessible name is the wrong one.
121
+ const select = expectAgentDrivable(canvas, {
122
+ name: "Profile in a field",
123
+ role: "combobox",
124
+ })
125
+
126
+ await expect(select).not.toHaveAttribute("aria-label")
127
+
128
+ await expect(select).toHaveAttribute("aria-describedby")
129
+ })
@@ -0,0 +1,200 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type {
3
+ ChangeEvent,
4
+ ComponentPropsWithRef,
5
+ ReactNode,
6
+ } from "react"
7
+
8
+ import {
9
+ CONTROL_SIZE_CLASS,
10
+ MIN_TOUCH_TARGET_CLASS,
11
+ } from "../controlStyles.ts"
12
+ import {
13
+ DISABLED_CLASS,
14
+ FOCUS_RING_CLASS,
15
+ } from "../intentStyles.ts"
16
+ import { toClassName } from "../toClassName.ts"
17
+
18
+ export type SelectOption = {
19
+ isDisabled?: boolean
20
+ label: string
21
+ value: string
22
+ }
23
+
24
+ export type SelectOptionGroup = {
25
+ label: string
26
+ options: SelectOption[]
27
+ }
28
+
29
+ export type SelectItem = SelectOption | SelectOptionGroup
30
+
31
+ /**
32
+ * The HTML attributes are passed through, and that is not
33
+ * convenience — it is what makes the `Field` slot work at all.
34
+ *
35
+ * `Field` clones `id`, `aria-describedby`, `aria-invalid` and
36
+ * `required` onto whatever control it is given. A component with a
37
+ * closed prop list **silently drops all four**: the field renders,
38
+ * the label still points at nothing, and every assertion about the
39
+ * control's own props passes because they never arrived. Found by
40
+ * the one test that asked the control what it had been handed.
41
+ *
42
+ * Same arrangement as `Button`, for the same reason.
43
+ */
44
+ export type SelectProps = Omit<
45
+ ComponentPropsWithRef<"select">,
46
+ "disabled" | "onChange" | "size" | "value"
47
+ > & {
48
+ className?: string
49
+ isDisabled?: boolean
50
+ /**
51
+ * The accessible name, when this is **not** inside a `Field`. A
52
+ * `<select>` with neither a `<label for>` nor an `aria-label` is
53
+ * announced as "combobox" and is unfindable by
54
+ * `getByRole("combobox", { name })` — twelve of the fleet's
55
+ * fourteen native selects are in exactly that state.
56
+ */
57
+ label?: string
58
+ onChange?: (value: string) => void
59
+ options: SelectItem[]
60
+ /**
61
+ * A disabled first entry, not a real option. Reads as a prompt to
62
+ * a sighted user and cannot be submitted, which is what a
63
+ * placeholder should mean — unlike an empty-valued option, which
64
+ * is selectable and silently posts "".
65
+ */
66
+ placeholder?: string
67
+ size?: ControlSize
68
+ /** **Initial** only, like every other value prop in this library. */
69
+ value?: string
70
+ }
71
+
72
+ const getIsGroup = (
73
+ item: SelectItem,
74
+ ): item is SelectOptionGroup => "options" in item
75
+
76
+ /**
77
+ * A styled native `<select>`, and the fleet's fourteen hand-rolled
78
+ * ones collapse onto it.
79
+ *
80
+ * ### There is no state kind here, and that is the finding
81
+ *
82
+ * Every other interactive component in this package owns its state
83
+ * through `@charcuterie/logic`. This one owns none, because the
84
+ * platform already does: a `<select>` holds the chosen option in the
85
+ * DOM, and putting a `useSinglePicker` beside it would create the
86
+ * exact conflict the state layer exists to prevent — two things
87
+ * believing they hold `value`, which is the Radix argument from
88
+ * `Popover` pointed at ourselves. So `value` seeds `defaultValue`
89
+ * and changes are reported outward.
90
+ *
91
+ * The consequence worth knowing: **`Select` is uncontrolled and
92
+ * `Tabs` is not**, and a reader should be able to tell why without
93
+ * asking. A tab bar's selection has no DOM representation, so
94
+ * something has to hold it; a select's does.
95
+ *
96
+ * ### Native, and what that rules out
97
+ *
98
+ * A `<select>` cannot render a rich option — an icon, two lines, a
99
+ * badge — and cannot filter. mux-magic has six controls that need
100
+ * one of those (`CommandPicker`, `PathPicker`, `LinkPicker`,
101
+ * `EnumPicker`, `AssFieldPicker`, `RenameTargetPicker`), and every
102
+ * one of them is a **`Combobox`**: a text input that filters a
103
+ * listbox. That is a different pattern with a different ARIA
104
+ * contract, it is P2 in the plan, and building a bare `Listbox`
105
+ * here that none of the six could adopt would have shipped a
106
+ * component with no caller.
107
+ *
108
+ * What native buys, and what a hand-rolled listbox has to rebuild
109
+ * from nothing: type-ahead, Home/End, PageUp/PageDown, the mobile
110
+ * wheel picker, form submission, `:invalid`, and autofill.
111
+ */
112
+ export const Select = ({
113
+ className,
114
+ isDisabled = false,
115
+ label,
116
+ onChange,
117
+ options,
118
+ placeholder,
119
+ size = "md",
120
+ value,
121
+ ...selectProps
122
+ }: SelectProps): ReactNode => (
123
+ <div className="relative inline-grid w-full items-center">
124
+ <select
125
+ {...selectProps}
126
+ aria-label={label}
127
+ className={toClassName(
128
+ "w-full cursor-pointer appearance-none rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard",
129
+ // Room for the chevron below, which sits in the same grid
130
+ // cell. A `<select>`'s own arrow is what `appearance-none`
131
+ // removes, and forgetting to put one back leaves a control
132
+ // that looks exactly like a text input.
133
+ "pe-9",
134
+ CONTROL_SIZE_CLASS[size],
135
+ MIN_TOUCH_TARGET_CLASS,
136
+ "hover:border-border-strong",
137
+ FOCUS_RING_CLASS,
138
+ DISABLED_CLASS,
139
+ className,
140
+ )}
141
+ defaultValue={
142
+ value ??
143
+ (placeholder === undefined ? undefined : "")
144
+ }
145
+ disabled={isDisabled}
146
+ onChange={(
147
+ changeEvent: ChangeEvent<HTMLSelectElement>,
148
+ ) => {
149
+ onChange?.(changeEvent.currentTarget.value)
150
+ }}
151
+ >
152
+ {placeholder === undefined ? null : (
153
+ <option disabled value="">
154
+ {placeholder}
155
+ </option>
156
+ )}
157
+
158
+ {options.map((item) =>
159
+ getIsGroup(item) ? (
160
+ <optgroup key={item.label} label={item.label}>
161
+ {item.options.map((option) => (
162
+ <option
163
+ disabled={option.isDisabled}
164
+ key={option.value}
165
+ value={option.value}
166
+ >
167
+ {option.label}
168
+ </option>
169
+ ))}
170
+ </optgroup>
171
+ ) : (
172
+ <option
173
+ disabled={item.isDisabled}
174
+ key={item.value}
175
+ value={item.value}
176
+ >
177
+ {item.label}
178
+ </option>
179
+ ),
180
+ )}
181
+ </select>
182
+
183
+ <svg
184
+ // Decoration. The control beside it already has a name, and an
185
+ // announced "chevron" is noise on every select in the fleet.
186
+ aria-hidden="true"
187
+ className="pointer-events-none absolute end-3 size-4 text-content-secondary"
188
+ fill="none"
189
+ focusable={false}
190
+ stroke="currentColor"
191
+ strokeLinecap="round"
192
+ strokeLinejoin="round"
193
+ strokeWidth={1.75}
194
+ viewBox="0 0 24 24"
195
+ xmlns="http://www.w3.org/2000/svg"
196
+ >
197
+ <path d="m6 9 6 6 6-6" />
198
+ </svg>
199
+ </div>
200
+ )
@@ -0,0 +1,67 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as SortableTableHeaderStories from "./SortableTableHeader.stories";
4
+
5
+ <Meta of={SortableTableHeaderStories} />
6
+
7
+ # SortableTableHeader
8
+
9
+ **The header cell, and nothing else.** No `DataTable`, no rows, no comparator — the plan is
10
+ explicit about that, and the reason is that sorting *data* is app logic that differs per
11
+ column type, while announcing *that* a column is sorted is markup every table gets wrong the
12
+ same way.
13
+
14
+ <Canvas of={SortableTableHeaderStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## `aria-sort` does not exist anywhere in the fleet
21
+
22
+ Not in mux-magic, not in rip-deck, not in castkit. mux-magic's `FileExplorerModal` — the one
23
+ sortable table there is — renders the state as a character:
24
+
25
+ ```tsx
26
+ {sortDirection === "asc" ? "▲" : "▼"}
27
+ ```
28
+
29
+ A screen reader announces that as "black up-pointing triangle", if the font has it at all —
30
+ and this sandbox's headless Chromium does not, so the same glyph measures **blank** in a
31
+ screenshot. Sighted users see a direction; everyone else gets nothing.
32
+
33
+ No automated check anywhere reports it. **axe has no rule for a missing `aria-sort`**,
34
+ because a table with none is a table that is simply not sorted as far as the accessibility
35
+ tree knows.
36
+
37
+ This is therefore the one component in M6 whose payoff is not duplication — there is a
38
+ single site — but a class of failure invisible to every gate the fleet has.
39
+
40
+ ## `none` is not the same as absent
41
+
42
+ | `aria-sort` | means |
43
+ | --- | --- |
44
+ | absent | this column **cannot** be sorted |
45
+ | `"none"` | sortable, not currently sorted |
46
+ | `"ascending"` / `"descending"` | this is the sorted column |
47
+
48
+ A table that omits the attribute on its unsorted columns tells a screen-reader user the other
49
+ four cannot be sorted at all. Exactly one column ever holds a direction.
50
+
51
+ <Canvas of={SortableTableHeaderStories.AllVariants} />
52
+
53
+ ## The cell announces, the button acts
54
+
55
+ `aria-sort` belongs on the `<th>`; the click target is a `<button>` inside it. Putting the
56
+ role on the button instead is the common mistake and it breaks the grid semantics — a `<th>`
57
+ that *is* a button is no longer a column header to anything reading the table structure.
58
+
59
+ ## The name stays the column's name
60
+
61
+ It is tempting to append the state — "Title, sorted ascending" — and that duplicates what
62
+ `aria-sort` already says, so the column is announced twice **and**
63
+ `getByRole("columnheader", { name: "Title" })` stops matching the moment somebody sorts it.
64
+
65
+ <Canvas of={SortableTableHeaderStories.AllStates} />
66
+
67
+ <Canvas of={SortableTableHeaderStories.Interactive} />