@openreachtech/furo-vue 1.6.0 → 1.7.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 (79) hide show
  1. package/README.ja.md +2 -0
  2. package/README.md +2 -0
  3. package/docs/design-tokens.md +419 -0
  4. package/docs/furo-accordion.md +109 -0
  5. package/docs/furo-alert-dialog.md +107 -0
  6. package/docs/furo-attachment.md +133 -0
  7. package/docs/furo-autocomplete-field.md +719 -0
  8. package/docs/furo-avatar-group.md +87 -0
  9. package/docs/furo-avatar.md +101 -0
  10. package/docs/furo-badge.md +126 -0
  11. package/docs/furo-breadcrumb.md +89 -0
  12. package/docs/furo-button.md +263 -0
  13. package/docs/furo-checkbox.md +384 -0
  14. package/docs/furo-collapsible.md +104 -0
  15. package/docs/furo-control-block.md +102 -0
  16. package/docs/furo-date-picker.md +270 -0
  17. package/docs/furo-date-time-picker.md +110 -0
  18. package/docs/furo-dialog.md +88 -0
  19. package/docs/furo-drawer.md +122 -0
  20. package/docs/furo-dropdown-menu.md +298 -0
  21. package/docs/furo-editable-field.md +391 -0
  22. package/docs/furo-editor.md +304 -0
  23. package/docs/furo-email-field.md +93 -0
  24. package/docs/furo-empty-state.md +97 -0
  25. package/docs/furo-error-state.md +97 -0
  26. package/docs/furo-file-field.md +163 -0
  27. package/docs/furo-icon.md +288 -0
  28. package/docs/furo-number-field.md +397 -0
  29. package/docs/furo-pagination.md +101 -0
  30. package/docs/furo-password-field.md +75 -0
  31. package/docs/furo-popover.md +114 -0
  32. package/docs/furo-radio.md +125 -0
  33. package/docs/furo-scroll-area.md +112 -0
  34. package/docs/furo-search-field.md +94 -0
  35. package/docs/furo-section-header.md +135 -0
  36. package/docs/furo-select.md +443 -0
  37. package/docs/furo-separator.md +87 -0
  38. package/docs/furo-sidebar.md +178 -0
  39. package/docs/furo-skeleton.md +92 -0
  40. package/docs/furo-splitter.md +103 -0
  41. package/docs/furo-stepper.md +127 -0
  42. package/docs/furo-switch.md +107 -0
  43. package/docs/furo-table.md +233 -0
  44. package/docs/furo-tabs.md +173 -0
  45. package/docs/furo-text-field.md +249 -0
  46. package/docs/furo-textarea.md +214 -0
  47. package/docs/furo-time-field.md +276 -0
  48. package/docs/furo-toast.md +217 -0
  49. package/docs/furo-toggle-group.md +123 -0
  50. package/docs/furo-toggle.md +104 -0
  51. package/docs/furo-tool-bar.md +88 -0
  52. package/docs/furo-tooltip.md +107 -0
  53. package/lib/components/atoms/FuroButton/FuroButtonContext.js +8 -8
  54. package/lib/components/atoms/FuroCheckbox/FuroCheckboxContext.js +1 -1
  55. package/lib/components/atoms/FuroSwitch/FuroSwitchContext.js +1 -1
  56. package/lib/components/atoms/FuroToggle/FuroToggleContext.js +1 -1
  57. package/lib/components/molecules/FuroAccordion/FuroAccordionContext.js +1 -1
  58. package/lib/components/molecules/FuroCollapsible/FuroCollapsibleContext.js +1 -1
  59. package/lib/components/molecules/FuroDropdownMenu/FuroDropdownMenuContext.js +1 -2
  60. package/lib/components/molecules/FuroPopover/FuroPopoverContext.js +1 -1
  61. package/lib/components/molecules/FuroSelect/FuroSelect.vue +8 -6
  62. package/lib/components/molecules/FuroSelect/FuroSelectContext.js +35 -0
  63. package/lib/components/molecules/FuroSelect/FuroSelectOptionText.vue +40 -0
  64. package/lib/components/molecules/FuroSelect/FuroSelectOptionTextContext.js +275 -0
  65. package/lib/components/molecules/FuroSplitter/FuroSplitterContext.js +1 -1
  66. package/lib/components/molecules/FuroToggleGroup/FuroToggleGroupContext.js +1 -1
  67. package/lib/components/molecules/FuroTooltip/FuroTooltipContext.js +1 -1
  68. package/lib/components/organisms/FuroTable/FuroTableContext.js +3 -3
  69. package/lib/components/organisms/FuroToast/FuroToastContext.js +6 -2
  70. package/lib/components/organisms/FuroToast/ToastQueue.js +4 -4
  71. package/nuxt/index.js +9 -9
  72. package/package.json +19 -17
  73. package/public/furo-vue/components.json +1 -1
  74. package/types/generated/components/atoms/FuroButton/FuroButtonContext.d.ts +4 -4
  75. package/types/generated/components/molecules/FuroSelect/FuroSelect.vue.d.ts +23 -0
  76. package/types/generated/components/molecules/FuroSelect/FuroSelectContext.d.ts +14 -0
  77. package/types/generated/components/molecules/FuroSelect/FuroSelectOptionText.vue.d.ts +50 -0
  78. package/types/generated/components/molecules/FuroSelect/FuroSelectOptionTextContext.d.ts +141 -0
  79. package/types/generated/components/organisms/FuroTable/FuroTableContext.d.ts +2 -2
@@ -0,0 +1,233 @@
1
+ # FuroTable
2
+
3
+ Domain-agnostic, presentational data table organism. Renders a native `<table>` from a `columns` + `rows` parcel and exposes per-cell slots, controlled sort, row selection, and loading/empty/error states.
4
+
5
+ | **Layer** | Organism (`lib/components/organisms/FuroTable/`) |
6
+ | **Base** | Native `<table>` (no headless table primitive) |
7
+ | **Selection control** | `FuroCheckbox` atom |
8
+ | **Sub-context** | `FuroTableRowItemContext` (per-row presentation) |
9
+
10
+ FuroTable is **controlled**: it emits sort and selection intent and re-reads its parcel. It never sorts, filters, paginates, or fetches data itself. Cell rendering, row actions, and column formatting live in the consumer app via slots — the library ships no renderer or action registry. A trailing per-row actions column (`row-actions` slot) and a footer region (`footer` slot) are opt-in structural slots for the common list-view layout.
11
+
12
+ ## Props
13
+
14
+ | Prop | Type | Description |
15
+ |------|------|-------------|
16
+ | `parcel` | `FuroTableParcel \| null` | Closed-set, JSON-serializable table state (below) |
17
+
18
+ ### `FuroTableParcel`
19
+
20
+ | Key | Type | Default | Description |
21
+ |-----|------|---------|-------------|
22
+ | `columns` | `Array<FuroTableColumn>` | `[]` | Column configuration |
23
+ | `rows` | `Array<object>` | `[]` | Raw row records (rendered in the order given) |
24
+ | `rowKey` | `string` | `'id'` | Field used as each row's unique key |
25
+ | `selectable` | `boolean` | `false` | Show the selection column + select-all checkbox |
26
+ | `selectedRowKeys` | `Array<string \| number> \| null` | `null` | Controlled selection. `null` = uncontrolled (table holds its own selection) |
27
+ | `sort` | `{ field: string, direction: 'asc' \| 'desc' } \| null` | `null` | Active sort indicator (controlled) |
28
+ | `loading` | `boolean` | `false` | Render the loading state |
29
+ | `errorMessage` | `string \| null` | `null` | When set, render the error state (takes precedence over loading/empty/rows) |
30
+ | `emptyText` | `string` | `'No records'` | Text for the default empty state |
31
+ | `virtual` | `FuroTableVirtual \| null` | `null` | When set, fixed-height virtual scrolling (below) |
32
+ | `totalRecords` | `number \| null` | `null` (falls back to `rows.length`) | Full dataset size — drives total scroll height in virtual mode |
33
+ | `rowOffset` | `number` | `0` | Global index of `rows[0]` — lets `rows` be a sparse window in virtual mode |
34
+ | `resizable` | `boolean` | `false` | Show drag handles on column edges (below) |
35
+ | `compact` | `boolean` | `false` | Compact density — denser rows, tighter padding, smaller text |
36
+ | `gridLines` | `boolean` | `false` | Vertical borders between columns |
37
+ | `rowActionsLabel` | `string` | `''` | Header text for the trailing row-actions column (only rendered when the `row-actions` slot is provided) |
38
+ | `rowActionsWidth` | `string` | `'6rem'` | CSS width of the row-actions column |
39
+
40
+ ### `FuroTableVirtual`
41
+
42
+ | Key | Type | Default | Description |
43
+ |-----|------|---------|-------------|
44
+ | `itemSize` | `number` | — | Fixed row height in px |
45
+ | `height` | `number` | — | Scroll viewport height in px |
46
+ | `overscan` | `number` | `4` | Extra rows rendered above and below the viewport |
47
+ | `endThreshold` | `number` | `8` | How close (in rows) a downward scroll must get to the loaded window's end before `reach-end` fires |
48
+
49
+ ### `FuroTableColumn`
50
+
51
+ | Key | Type | Default | Description |
52
+ |-----|------|---------|-------------|
53
+ | `field` | `string` | — | Key into each row record |
54
+ | `label` | `string` | `field` | Header text |
55
+ | `sortable` | `boolean` | `false` | Clicking the header label emits `sort:change` |
56
+ | `align` | `'start' \| 'center' \| 'end'` | `'start'` | Cell text alignment |
57
+ | `width` | `string` | — | Initial CSS width (e.g. `'12rem'`). Otherwise the fixed layout shares width equally |
58
+
59
+ State precedence: **error → loading → empty → rows**.
60
+
61
+ ## Loading
62
+
63
+ `parcel.loading` draws one of two presentations, chosen by whether rows already sit on screen:
64
+
65
+ | Rows | Presentation |
66
+ |------|--------------|
67
+ | none | a centered spinner row inside the body, so a first load never dims an empty table |
68
+ | some | a floating overlay above the whole table, so a refresh keeps its rows and shifts no layout |
69
+
70
+ Under both, the scroll region carries `inert` and the root carries `aria-busy="true"`. Every control inside therefore stops answering pointer and keyboard alike: the select-all and per-row checkboxes render disabled, a sortable header stops cycling, a resize handle stops dragging, and whatever you passed to `cell` or `row-actions` goes unreachable with them. Note that `inert` also drops the table out of the accessibility tree for as long as it lasts, which is why the root announces itself busy. Clearing `loading` restores everything.
71
+
72
+ One gap is worth knowing: a menu or popover that portals its content renders outside the scroll region, so a dropdown already open when the load began stays interactive even though its trigger does not. Close it yourself if a refresh must not act on the row underneath.
73
+
74
+ ## Events
75
+
76
+ | Event | Payload | When |
77
+ |-------|---------|------|
78
+ | `sort:change` | `{ field: string, direction: 'asc' \| 'desc' \| null }` | A sortable header is clicked. Cycles `asc → desc → null` (the third click clears the sort; `direction: null` restores the default order) |
79
+ | `selection:change` | `{ selectedRowKeys: Array<string \| number> }` | A row or the select-all checkbox toggles |
80
+ | `update:selectedRowKeys` | `Array<string \| number>` | `v-model:selectedRowKeys` sync (fires with `selection:change`) |
81
+ | `range:change` | `{ first: number, last: number }` | Virtual mode only — the visible global row window moved. Fires on mount and thereafter only when the window changes. Best for **sparse-window** consumers that fetch exactly the visible window |
82
+ | `reach-end` | `{ first: number, last: number, loadedCount: number, totalRecords: number }` | Virtual mode only — a **downward** scroll came within `endThreshold` rows of the loaded window's end while more records remain. Also fires on mount, and again whenever the loaded window changes size, so a window still holding unloaded rows never waits for a scroll. Best for **infinite-append** consumers: fires once per loaded window, re-arming when it grows and when a refresh shrinks it, never on upward scroll and never before the end |
83
+ | `row:click` | `{ row: object, rowKey: string \| number \| null }` | A data row is clicked. Use it to navigate to a detail view. The built-in selection cell stops propagation; interactive `cell`-slot content (buttons, dropdowns, links) should `@click.stop` to opt out |
84
+
85
+ ## Slots
86
+
87
+ | Slot | Scope | Fallback |
88
+ |------|-------|----------|
89
+ | `header-cell` | `{ column }` | `column.label` |
90
+ | `cell` | `{ row, column, value }` | the raw cell value |
91
+ | `loading` | — | a spinner icon |
92
+ | `empty` | — | `parcel.emptyText` |
93
+ | `error` | `{ message }` | `parcel.errorMessage` |
94
+ | `placeholder` | `{ index }` | a shimmer skeleton bar (virtual mode, unloaded row) |
95
+ | `row-actions` | `{ row }` | (none) — providing this slot adds a trailing actions column |
96
+ | `pagination` | — | (empty) — host a `FuroPagination` here |
97
+ | `footer` | `{ selection }` | (none) — providing this slot renders a footer region below the table. `selection` is `{ keys, count }` |
98
+
99
+ The sort trigger is the **header label** only (not the whole header cell), so it does not conflict with the resize handle. It is keyboard-accessible (`role="button"`, `tabindex`, Enter/Space). Clicking cycles through three states — **ascending → descending → unsorted** — so a third click restores the default order. A sortable column always shows a sort icon: a dimmed neutral (bidirectional) icon by default, brightening to a directional up/down icon once it is the active sort.
100
+
101
+ ## Column resizing
102
+
103
+ Set `parcel.resizable` to show a drag handle on the right edge of each header. Dragging sets an explicit px width for that column. The table uses `table-layout: fixed` with a `<colgroup>`, so:
104
+
105
+ - Column widths are stable across loading / empty / error / data states — no width jump when content changes.
106
+ - Resized widths and configured `column.width` are applied via the `<colgroup>`; unsized columns share the remaining width equally.
107
+ - Minimum column width is 80px.
108
+
109
+ Resizing is **fit mode** (like a contained data table): the table's total width stays fixed, and dragging a column trades width with the column to its right (both clamped to the minimum). The table never grows from a resize, so a column can never be pushed off-screen with an unreachable handle. Consequently the **last column has no resize handle** (no neighbour to trade with). On the first resize all column widths are pinned to their current rendered size so the rest stay put.
110
+
111
+ The scroll container is `overflow: auto`, so horizontal scroll still appears if the *content itself* is too wide (e.g. many columns or explicit `column.width`s exceeding the container) — resizing just never triggers it.
112
+
113
+ The drag uses pointer capture on the handle, so resizing continues smoothly even when the cursor leaves the handle.
114
+
115
+ ## Density & grid lines
116
+
117
+ Set `parcel.compact` for a denser layout (shorter rows, tighter padding, smaller text) — useful for data-dense list views. Set `parcel.gridLines` to draw vertical borders between columns.
118
+
119
+ Cell padding is a token pair, so an app retunes density without targeting furo's class names:
120
+
121
+ | Token | Default | Controls |
122
+ |-------|---------|----------|
123
+ | `--size-table-cell-inline` | `1rem` | Inline padding of every header, body, placeholder and actions cell |
124
+ | `--size-table-cell-block` | `0` | Block padding of the same cells. Rows keep their own height, so a value only grows a row once content plus padding exceeds it |
125
+
126
+ Compact density and the selection (checkbox) cell derive from `--size-table-cell-inline`, so one value keeps them in proportion: a compact cell takes half of it, the selection cell takes half of it, and the compact selection cell takes `0.375` times it. At the default `1rem` these come to the `0.5rem`, `0.5rem` and `0.375rem` the table has always drawn.
127
+
128
+ ```css
129
+ :root {
130
+ --size-table-cell-inline: 0.75rem;
131
+ --size-table-cell-block: 0.5rem;
132
+ }
133
+ ```
134
+
135
+ ## Row interaction
136
+
137
+ Listen to `row:click` to make rows navigate (the CRM list view links each row to its detail page). The payload carries the full `row` and its `rowKey`, so the consumer owns the navigation. The built-in selection checkbox cell stops click propagation, so toggling selection never fires `row:click`. Any interactive content you put in a `cell` slot (action buttons, dropdown menus, links) should add `@click.stop` so it does not also trigger row navigation.
138
+
139
+ ## Overflow & truncation
140
+
141
+ Default header and cell text truncate with an ellipsis (the layout is `table-layout: fixed`). Truncation is applied to wrapper spans (`.label-text`, `.cell-text`) — **not** the `<td>` — so interactive slot content (buttons, dropdown triggers, focus rings) is never clipped. Custom `cell` slot content controls its own overflow.
142
+
143
+ ## Row-actions column & footer
144
+
145
+ Two structural slots cover the standard list-view chrome without leaking domain logic into the library:
146
+
147
+ - **`row-actions`** — providing this slot adds a dedicated trailing column, so per-row action buttons or an action menu (e.g. `FuroDropdownMenu`) stay aligned in their own column instead of being squeezed into the last `cell`. The slot scope gives you the raw `row` (which carries the row key). The actions `<td>` stops click propagation, so pressing an action never fires `row:click`. The header shows `parcel.rowActionsLabel` (empty by default) and the column width is `parcel.rowActionsWidth` (`6rem` by default). It works in both normal and virtual modes; unloaded virtual rows keep the same cell structure as loaded ones, so the placeholder spans only the data columns and the selection and actions cells render empty beside it. Keeping those cells is what stops the checkbox column from blinking out of the layout while a virtual table scrolls through unloaded rows.
148
+ - **`footer`** — providing this slot renders a footer region below the table (and below `pagination`), suited to a bulk-action bar or a summary row. The slot scope exposes a single `selection` object (`{ keys, count }`), so a bar like "3 selected — Delete" needs no extra wiring.
149
+
150
+ ```vue
151
+ <FuroTable :parcel="{ columns, rows, selectable: true, rowActionsLabel: 'Actions' }">
152
+ <template #row-actions="{ row }">
153
+ <FuroButton :parcel="{ variant: 'ghost', size: 'sm' }" @click="onEdit(row)">
154
+ Edit
155
+ </FuroButton>
156
+ </template>
157
+
158
+ <template #footer="{ selection }">
159
+ <span v-if="selection.count > 0">{{ selection.count }} selected</span>
160
+ </template>
161
+ </FuroTable>
162
+ ```
163
+
164
+ ## Column types via the `cell` slot
165
+
166
+ The table is presentational, so any column type (inline-editable fields, single action buttons, multi-action dropdown menus, badges, links, etc.) is rendered by the consumer through the `cell` slot, keyed on `column.field`. The library ships no editor or action registry. See the playground "Editable + action columns" and "Actions menu (dropdown)" samples.
167
+
168
+ ## Usage
169
+
170
+ ```vue
171
+ <FuroTable
172
+ :parcel="{
173
+ columns: [
174
+ { field: 'name', label: 'Name', sortable: true },
175
+ { field: 'email', label: 'Email', sortable: true },
176
+ { field: 'role', label: 'Role', align: 'end' },
177
+ ],
178
+ rows: members,
179
+ selectable: true,
180
+ selectedRowKeys: selectedKeys,
181
+ sort: activeSort,
182
+ }"
183
+ @sort:change="onSortChange"
184
+ @selection:change="onSelectionChange"
185
+ >
186
+ <template #cell="{ column, value }">
187
+ <RoleBadge v-if="column.field === 'role'" :role="value" />
188
+ <template v-else>{{ value }}</template>
189
+ </template>
190
+
191
+ <template #pagination>
192
+ <FuroPagination :parcel="{ limit: 20, totalRecords: total }" />
193
+ </template>
194
+ </FuroTable>
195
+ ```
196
+
197
+ The consumer owns the data lifecycle: re-sort `rows` and re-pass `parcel.sort` on `sort:change`, and re-pass `selectedRowKeys` on `selection:change` (or omit it for uncontrolled selection).
198
+
199
+ ## Virtual scroll
200
+
201
+ For large datasets (thousands of rows), set `parcel.virtual` to enable fixed-height windowing. The table renders only the rows in the viewport (plus overscan), padded by leading/trailing spacer rows so scroll geometry stays exact. The header stays sticky.
202
+
203
+ It is **controlled and sparse-aware**, matching a lazy-fetch model:
204
+
205
+ - `totalRecords` drives the full scroll height.
206
+ - `rows` may be just the loaded window; `rowOffset` is the global index of `rows[0]`.
207
+ - On scroll, the table emits `range:change { first, last }`. The consumer fetches that window (e.g. via the ListView adapter / GraphQL) and re-passes `rows` + `rowOffset`.
208
+ - Indices in the visible window that are not yet loaded render as placeholder rows (a shimmer skeleton by default, or the `placeholder` slot). This makes **async** lazy-loading seamless: emit a fetch on `range:change`, render skeletons while it resolves, then re-pass `rows`. Use a request token to discard superseded windows.
209
+
210
+ ```vue
211
+ <FuroTable
212
+ :parcel="{
213
+ columns,
214
+ rows: loadedWindow, // sparse window
215
+ rowOffset: windowOffset, // global index of loadedWindow[0]
216
+ totalRecords: 1000,
217
+ virtual: { itemSize: 40, height: 400 },
218
+ }"
219
+ @range:change="loadWindow" // fetch rows for { first, last }
220
+ />
221
+ ```
222
+
223
+ The fixed-height windowing is hand-rolled (no virtualizer dependency). The initial window is the consumer's responsibility (the table emits `range:change` on scroll, not on mount), so load the first window before render.
224
+
225
+ ## Not in MVP
226
+
227
+ - **Dynamic / measured row height** — virtual scroll is fixed-height (`itemSize`) only.
228
+ - **Inline / bulk edit host** — deferred to P3; build on top via the `cell` slot.
229
+ - **Local sorting / filtering / pagination** — out of scope by design; the table is controlled.
230
+
231
+ ## Playground
232
+
233
+ `/samples/components/playground` → **Table** tab.
@@ -0,0 +1,173 @@
1
+ # FuroTabs
2
+
3
+ Organism — tabbed layout built on a headless tabs primitive (`Root` + `List` +
4
+ `Trigger` + `Content` + optional `Indicator`). Renders a trigger row from a
5
+ declarative `tabs` list and a panel per tab via named slots, tracking the active
6
+ tab and emitting changes. Supports two visual variants (`line`, `solid`), a
7
+ scrollable trigger row with navigation buttons, and a panel-less menu mode.
8
+ Domain-agnostic: no GraphQL, router, or Nuxt imports — the consumer wires the
9
+ emitted value to its own state.
10
+
11
+ Location: `lib/components/organisms/FuroTabs/`
12
+
13
+ ## Props
14
+
15
+ | Prop | Type | Default | Notes |
16
+ |------|------|---------|-------|
17
+ | `parcel` | `FuroTabsParcel \| null` | `null` | Reactive behavior object (below) |
18
+
19
+ ### `parcel`
20
+
21
+ | Key | Type | Default | Notes |
22
+ |-----|------|---------|-------|
23
+ | `tabs` | `Array<FuroTabsTab>` | `[]` | Tab descriptors that drive the trigger row and panel slots (below). |
24
+ | `value` | `string \| null` | — | Active tab value, where the presence of the key decides ownership. Absent, the component manages its own active tab, defaulting to the first tab. Present with a value, the parent owns the active tab. Present and `null`, the parent owns it and no tab is active. |
25
+ | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Layout direction of the trigger row. |
26
+ | `variant` | `'line' \| 'solid'` | `'line'` | `line` is an underlined trigger row with a sliding indicator; `solid` is a segmented control where the active trigger gets a raised background. |
27
+ | `scrollable` | `boolean` | `false` | When `true`, the trigger row scrolls and previous/next navigation buttons appear once it overflows. |
28
+ | `showPanels` | `boolean` | `true` | When `false`, no panels render — the component is a navigation menu only (the active value drives an external `router-view`). |
29
+ | `showIndicator` | `boolean` | `true` | Renders the animated sliding indicator. Applies to the `line` variant only — the `solid` variant marks the active tab with its background instead. |
30
+ | `disabled` | `boolean` | `false` | Disables the whole tab set. |
31
+ | `scrollPreviousLabel` | `string` | `'Scroll to previous tabs'` | Accessible name of the scroll-toward-start button. Set it to translate the control. |
32
+ | `scrollNextLabel` | `string` | `'Scroll to next tabs'` | Accessible name of the scroll-toward-end button. Set it to translate the control. |
33
+
34
+ ### `FuroTabsTab`
35
+
36
+ | Key | Type | Default | Notes |
37
+ |-----|------|---------|-------|
38
+ | `value` | `string` | — | Unique tab identifier; also the default panel slot name. It reaches the DOM inside the trigger and panel element ids, so pass a token valid in an id (see the notes below). |
39
+ | `label` | `string` | — | Trigger text (overridable via the `label-<value>` slot). |
40
+ | `slotName` | `string` | `value` | Overrides the panel slot name for this tab. |
41
+ | `disabled` | `boolean` | `false` | Disables this single tab. |
42
+ | `attrs` | `Record<string, string \| number \| boolean>` | — | Attributes set on this tab's trigger element, for a state the component does not own (`aria-current`, your own `data-*`). The component owns the trigger's element shape, its identity and its active state, so the bag drops those keys rather than applying them: `value`, `disabled`, `id`, `role`, `type`, `aria-selected`, `aria-controls`, `data-state`, `data-disabled` and `data-orientation`. |
43
+ | `class` | `string` | — | Class name added to this tab's trigger element, alongside the component's own `tab` class. |
44
+
45
+ ## Events
46
+
47
+ | Event | Payload | When |
48
+ |-------|---------|------|
49
+ | `update:value` | `string` | Active tab changed — enables `v-model:value`. |
50
+ | `tab:change` | `{ value: string }` | Active tab changed. |
51
+
52
+ ## Slots
53
+
54
+ | Slot | Purpose |
55
+ |------|---------|
56
+ | `<value>` (or `<slotName>`) | Panel content for the matching tab. One slot per tab, keyed by the tab's `value` (or its `slotName` override). |
57
+ | `label-<value>` | Custom trigger content for the matching tab, receiving `{ value, label }`. When omitted, the trigger shows `label`. |
58
+
59
+ ## Usage
60
+
61
+ ### Uncontrolled
62
+
63
+ ```vue
64
+ <FuroTabs
65
+ :parcel="{ tabs: [
66
+ { value: 'profile', label: 'Profile' },
67
+ { value: 'settings', label: 'Settings' },
68
+ ]}"
69
+ @tab:change="onTabChange($event.value)"
70
+ >
71
+ <template #profile>...profile panel...</template>
72
+ <template #settings>...settings panel...</template>
73
+ </FuroTabs>
74
+ ```
75
+
76
+ ### Controlled (`v-model:value`)
77
+
78
+ ```vue
79
+ <FuroTabs
80
+ :parcel="{ value: activeTab, tabs }"
81
+ @tab:change="activeTab = $event.value"
82
+ >
83
+ <template #profile>...</template>
84
+ <template #settings>...</template>
85
+ </FuroTabs>
86
+ ```
87
+
88
+ ### Vertical orientation
89
+
90
+ ```vue
91
+ <FuroTabs :parcel="{ orientation: 'vertical', tabs }">
92
+ <template #profile>...</template>
93
+ <template #settings>...</template>
94
+ </FuroTabs>
95
+ ```
96
+
97
+ ### Solid variant
98
+
99
+ ```vue
100
+ <FuroTabs :parcel="{ variant: 'solid', tabs }">
101
+ <template #profile>...</template>
102
+ <template #settings>...</template>
103
+ </FuroTabs>
104
+ ```
105
+
106
+ ### Scrollable trigger row
107
+
108
+ ```vue
109
+ <FuroTabs :parcel="{ scrollable: true, tabs: manyTabs }">
110
+ <!-- panel slots per tab -->
111
+ </FuroTabs>
112
+ ```
113
+
114
+ ### Tab menu (no panels, router-driven)
115
+
116
+ ```vue
117
+ <FuroTabs
118
+ :parcel="{ showPanels: false, value: route.name, tabs }"
119
+ @tab:change="router.push({ name: $event.value })"
120
+ />
121
+ <router-view />
122
+ ```
123
+
124
+ ### Custom trigger content
125
+
126
+ ```vue
127
+ <FuroTabs :parcel="{ tabs }">
128
+ <template #label-profile>★ Profile</template>
129
+ <template #profile>...</template>
130
+ </FuroTabs>
131
+ ```
132
+
133
+ ### Marking a tab with consumer state
134
+
135
+ ```vue
136
+ <FuroTabs
137
+ :parcel="{ tabs: [
138
+ { value: 'stage-2', label: '2. Review', class: 'completed' },
139
+ { value: 'stage-3', label: '3. Waiting', attrs: { 'aria-current': 'step' } },
140
+ ]}"
141
+ />
142
+ ```
143
+
144
+ ### Translated scroll buttons
145
+
146
+ ```vue
147
+ <FuroTabs
148
+ :parcel="{
149
+ scrollable: true,
150
+ scrollPreviousLabel: '前のタブへスクロール',
151
+ scrollNextLabel: '次のタブへスクロール',
152
+ tabs: manyTabs,
153
+ }"
154
+ />
155
+ ```
156
+
157
+ ## Notes
158
+
159
+ - The active trigger is marked with `data-state="active"` for styling; disabled
160
+ triggers are non-interactive.
161
+ - Root CSS class: `unit-tabs` (inside `@layer furo`). Modifier classes: `line` /
162
+ `solid` (variant), `vertical` (orientation), `scrollable` (scroll mode), and
163
+ `disabled` (whole control disabled).
164
+ - When `scrollable` is `true`, navigation buttons are auto-disabled at the
165
+ start/end of the scroll range and recompute on scroll and whenever the
166
+ trigger row's viewport is resized, whether or not the window itself changed
167
+ size. A platform without `ResizeObserver` keeps the scroll-event recompute.
168
+ - A tab's `value` is interpolated into the trigger and panel element ids, and
169
+ into the `aria-controls` / `aria-labelledby` pair built from them. A value
170
+ carrying a space or another character invalid in an id breaks that pairing
171
+ silently, with no console warning. Pass a token the application controls
172
+ (`stage-3`, `orders`), never a raw server string such as a status name, and
173
+ map back to the server key yourself.
@@ -0,0 +1,249 @@
1
+ # `<FuroTextField>` Interface Specification
2
+
3
+ ## Overview
4
+
5
+ `<FuroTextField>` is a text input Atom in the Furo-Vue component system. It is an opinionated abstraction over a native `<input type="text">` element — it does not mirror raw HTML but encapsulates structure, accessibility, and controlled/uncontrolled state management internally.
6
+
7
+ ---
8
+
9
+ ## Props
10
+
11
+ `<FuroTextField>` uses **two distinct mechanisms** for receiving data from the parent, each with a different responsibility.
12
+
13
+ ### Rule: `parcel` vs Fallthrough Attributes
14
+
15
+ | Mechanism | When to use | Rendered as |
16
+ |------------------------|---------------------------------------------------------------------|----------------|
17
+ | `:parcel` | Props that **control component behavior** (value, invalid state, …) | Not HTML attrs |
18
+ | Fallthrough attributes | Native **HTML attributes** that belong on the `<input>` element | Direct HTML |
19
+
20
+ The distinction maps cleanly to HTML semantics: if it's part of HTML, it should appear as HTML. If it controls Furo's internal logic, it goes into `parcel`.
21
+
22
+ ```html
23
+ <!-- ✅ Correct -->
24
+ <FuroTextField
25
+ :parcel="context.usernameParcel"
26
+ :disabled="context.usernameParcel.disabled"
27
+ />
28
+
29
+ <!-- context.usernameParcel = { value: '...', invalid: true, ... } -->
30
+ ```
31
+
32
+ > **Why `parcel` exists:** Components with 10+ individual props make it hard to review the wiring between HTML template and Context members. `parcel` consolidates behavior-controlling props into one reviewable object.
33
+ > **Why fallthrough exists:** HTML attributes are structurally part of HTML — they belong in the template where the HTML structure is visible.
34
+
35
+ ---
36
+
37
+ ### `parcel` shape
38
+
39
+ Behavior-controlling props passed as a single object.
40
+
41
+ | Key | Type | Required | Default | Description |
42
+ |-----------|-------------------------|----------|---------|---------------------------------------------|
43
+ | `value` | `string \| number \| null` | No | `null` | Controlled value. Synced to internal `ref`. |
44
+ | `invalid` | `boolean` | No | `false` | Applies invalid/error visual state. |
45
+
46
+ > All values must be **JSON-serializable** (no functions, no class instances).
47
+
48
+ ---
49
+
50
+ ### Fallthrough Attributes (HTML attributes)
51
+
52
+ Native HTML attributes passed directly on the component element. Vue automatically forwards them to the underlying `<input>`.
53
+
54
+ | Attribute | Type | Description |
55
+ |----------------|-----------|----------------------------------------------|
56
+ | `disabled` | `boolean` | Disables user interaction. |
57
+ | `required` | `boolean` | Marks the field as required. |
58
+ | `placeholder` | `string` | Placeholder text displayed inside the input. |
59
+ | `name` | `string` | Form field name. |
60
+ | `autocomplete` | `string` | Browser autocomplete hint. |
61
+
62
+ > The `type` attribute is intentionally excluded from fallthrough — the component always renders `type="text"`.
63
+
64
+ ```html
65
+ <FuroTextField
66
+ :parcel="context.usernameParcel"
67
+ :disabled="context.usernameParcel.disabled"
68
+ placeholder="Enter your username"
69
+ name="username"
70
+ autocomplete="username"
71
+ />
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Emits
77
+
78
+ Each emit passes a **`TextFieldEmitPayload` instance** directly as the event argument. In the template, `$event` is that `TextFieldEmitPayload` instance.
79
+
80
+ ```js
81
+ // Inside <FuroTextField>
82
+ emit('change-value', TextFieldEmitPayload.create({ rawEvent }))
83
+ ```
84
+
85
+ ```html
86
+ <!-- ✅ Correct — $event is the TextFieldEmitPayload instance -->
87
+ <FuroTextField
88
+ :parcel="context.buildTextFieldParcel()"
89
+ @change-value="context.onChangeValue({ payload: $event })"
90
+ />
91
+ ```
92
+
93
+ > **Event handler style:** In the parent Context, always use **single object parameter destructuring**. The template wraps `$event` into `{ payload: $event }` when calling Context methods.
94
+
95
+ ---
96
+
97
+ ### `@change-value`
98
+
99
+ Emitted on every `input` event (i.e., each keystroke).
100
+
101
+ ```html
102
+ <FuroTextField
103
+ :parcel="context.buildTextFieldParcel()"
104
+ @change-value="context.onChangeValue({ payload: $event })"
105
+ />
106
+ ```
107
+
108
+ ### `@commit-value`
109
+
110
+ Emitted on `blur` event or `Enter` key press.
111
+
112
+ ```html
113
+ <FuroTextField
114
+ :parcel="context.buildTextFieldParcel()"
115
+ @commit-value="context.onCommitValue({ payload: $event })"
116
+ />
117
+ ```
118
+
119
+ ### `@update:value`
120
+
121
+ Emitted alongside both `@change-value` and `@commit-value` to support `v-model:value`. The argument is the raw string value (not a payload instance).
122
+
123
+ > **Naming rationale:**
124
+ > `@change-value` replaces the potentially confusing `@input` native name, making developer intent explicit.
125
+ > `@commit-value` replaces `@change` to avoid collision with native `change` event semantics and is more readable.
126
+ > Furo names are based on what the parent component *wants*, not on internal browser events.
127
+
128
+ > **Emitter responsibility:** `<FuroTextField>` **always emits** regardless of validity. The parent (Context) decides how to respond. The component never cancels an emit based on value state.
129
+
130
+ ---
131
+
132
+ ## Event Handler Style in Context
133
+
134
+ All event handlers use **single object parameter destructuring** — no exceptions. This keeps Context code consistent with the rest of the ORT coding style.
135
+
136
+ ```js
137
+ // ✅ Correct
138
+ onChangeValue ({
139
+ payload
140
+ }) {
141
+ const isValid = this.validatesEmail({
142
+ payload
143
+ })
144
+
145
+ this.statusReactive.isEmailInvalid = !isValid
146
+ }
147
+
148
+ // ❌ Wrong — single positional argument breaks consistency
149
+ onChangeValue (payload) {
150
+ ...
151
+ }
152
+ ```
153
+
154
+ > **Why not allow `(payload)` as an exception?** Remembering exceptional rules wastes mental resources. `EmitPayload` is designed to be expressive enough that `extra` parameters are never needed. If extra parameters become necessary in the future, it will be addressed as a major version upgrade of furo.
155
+
156
+ ---
157
+
158
+ ## `TextFieldEmitPayload` API
159
+
160
+ The payload object received in all event handlers exposes the following members:
161
+
162
+ | Member | Type | Description |
163
+ |-------------------------|--------------------|----------------------------------------------------------------------------|
164
+ | `.value` | `string \| null` | Current raw string value of the input. |
165
+ | `.trimmedValue` | `string \| null` | Whitespace-trimmed version of `.value` (getter form). |
166
+ | `.extractTrimmedValue()` | `string \| null` | Whitespace-trimmed version of `.value`. |
167
+ | `.hasEmptyText()` | `boolean` | `true` if `.value` is empty or the control is missing. |
168
+ | `.hasEmptyTrimmedText()`| `boolean` | `true` if `.extractTrimmedValue()` is empty or the control is missing. |
169
+ | `.hasEnteredText()` | `boolean` | `true` if `.value` has at least one character (whitespace counts). |
170
+ | `.hasEnteredTrimmedText()`| `boolean` | `true` if `.extractTrimmedValue()` has at least one non-whitespace character. |
171
+
172
+ ```js
173
+ onChangeValue ({
174
+ payload
175
+ }) {
176
+ if (payload.hasEmptyTrimmedText()) {
177
+ this.statusReactive.isEmailInvalid = true
178
+ return
179
+ }
180
+
181
+ this.statusReactive.isEmailInvalid = !this.validatesEmail({ payload })
182
+ }
183
+ ```
184
+
185
+ ---
186
+
187
+ ## `v-model` Support
188
+
189
+ `v-model:value` is supported as syntactic sugar over `:parcel` value binding + `@update:value`.
190
+
191
+ ```html
192
+ <!-- These two are equivalent -->
193
+ <FuroTextField v-model:value="context.someRef.value" />
194
+
195
+ <FuroTextField
196
+ :parcel="{
197
+ value: context.someRef.value
198
+ }"
199
+ @update:value="context.someRef.value = $event"
200
+ />
201
+ ```
202
+
203
+ > A watcher **inside** `<FuroTextField>` synchronizes both `parcel.value` and `attrs.value` with the internal `ref`. No watcher is required in the parent component.
204
+
205
+ ---
206
+
207
+ ## Controlled vs. Uncontrolled State
208
+
209
+ `<FuroTextField>` supports **both modes**:
210
+
211
+ | Mode | How |
212
+ |--------------|-------------------------------------------------------------------|
213
+ | Controlled | Pass `:parcel="{ value: ... }"` or `v-model:value`. Internal ref syncs from prop. |
214
+ | Uncontrolled | Omit `value` in parcel. Internal ref manages its own state. |
215
+
216
+ > The watcher inside `setupComponent()` reads from `parcel.value` first, then falls back to `attrs.value`. If both are absent, the internal value is `null`.
217
+
218
+ ---
219
+
220
+ ## Example: Validation in Parent Context
221
+
222
+ When a parent needs to validate on change, use `@change-value` — no watcher needed:
223
+
224
+ ```js
225
+ // In Context class
226
+ onChangeValue ({
227
+ payload
228
+ }) {
229
+ const isValid = this.validatesEmail({
230
+ payload
231
+ })
232
+
233
+ this.statusReactive.isEmailInvalid = !isValid
234
+ }
235
+ ```
236
+
237
+ ```html
238
+ <FuroTextField
239
+ :parcel="{
240
+ value: context.emailRef.value,
241
+ invalid: context.statusReactive.isEmailInvalid,
242
+ }"
243
+ :required="true"
244
+ placeholder="Enter your email"
245
+ @change-value="context.onChangeValue({ payload: $event })"
246
+ />
247
+ ```
248
+
249
+ ---