From 849eac68eda5966998d3768d9073bff75476b9c5 Mon Sep 17 00:00:00 2001 From: Florian Laabs Date: Thu, 29 Oct 2026 23:50:32 +0100 Subject: [PATCH] feat: add opt-in extensions from a terminal desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extensions found while using WireLayer in a desktop terminal app (Tauri, Vue). All opt-in except the positioner fix: without the new props, defaults, DOM, ARIA and keyboard stay as they were. - Fix: popups with a positioner stay above dialogs – the theme gives Ark's positioners z-index: var(--z-index); Vue patched Ark's inline z-index away on every render - Values that are never empty: RadioGroup and SegmentedControl with required, NativeSelect without placeholder, NumberInput emptyFallback ('min' | 'previous'), Select emptyValue (e.g. "") – narrower v-model / onValueChange types - Focus: data-autoselect in Dialog, focusFirst() (DialogContent in Vue, a helper for React), Wizard focusOnStepChange - Wizard and Stepper: finishLabel; validate may return a text that is shown as the step's error - Progress status 'neutral', Avatar initials, Card size 'sm', Code in the main package (InlineCode's look without shiki) - resolveTokens(), watchTokens() and useResolvedTokens() for canvas libraries - ContextMenu before-open: second argument { source, focused } - Tabs: TabsTrigger controls, null value with the first tab reachable - Vite plugin: imports added while vite dev runs reach Tailwind (a file in Vite's cache folder) - rounded-card and rounded-panel count as radii for tailwind-merge - Docs: migration guide, implementation notes, regenerated MCP data Signed-off-by: Florian Laabs --- .changeset/compact-display.md | 19 + .changeset/context-menu-keyboard.md | 10 + .changeset/dialog-focus.md | 15 + .changeset/non-empty-values.md | 13 + .changeset/number-input-empty-fallback.md | 11 + .changeset/positioner-z-index.md | 9 + .changeset/resolve-tokens.md | 10 + .changeset/select-empty-value.md | 8 + .changeset/tabs-panels-elsewhere.md | 10 + .changeset/vite-plugin-dev-imports.md | 8 + .changeset/wizard-texts.md | 8 + apps/storybook/src/Migration.mdx | 16 + docs/implementation-notes.md | 18 + packages/mcp/data/components.json | 276 +++++++++++-- .../src/components/Avatar/Avatar.stories.tsx | 12 + .../native/src/components/Avatar/Avatar.tsx | 17 +- .../components/Progress/Progress.stories.tsx | 1 + .../src/components/Progress/Progress.tsx | 7 +- .../src/components/RadioGroup/RadioGroup.tsx | 25 +- packages/parity/e2e/navigation.spec.ts | 12 + packages/parity/e2e/overlays.spec.ts | 22 + .../native-data-avatar--own-initials.png | Bin 0 -> 6291 bytes .../native-feedback-progress--compact.png | Bin 7082 -> 7871 bytes .../react-data-avatar--own-initials.png | Bin 0 -> 6425 bytes .../react-data-card--small.png | Bin 0 -> 16281 bytes .../react-data-code--playground.png | Bin 0 -> 10893 bytes .../react-feedback-progress--compact.png | Bin 11436 -> 13165 bytes ...eact-forms-numberinput--empty-fallback.png | Bin 0 -> 11750 bytes ...react-forms-select--empty-string-value.png | Bin 0 -> 7116 bytes .../react-forms-wizard--own-texts.png | Bin 0 -> 12100 bytes ...eact-navigation-tabs--panels-elsewhere.png | Bin 0 -> 10389 bytes .../react-overlays-dialog--own-steps.png | Bin 0 -> 6243 bytes ...eact-overlays-dialog--selected-on-open.png | Bin 0 -> 6681 bytes ...react-utilities-hooks--resolved-tokens.png | Bin 0 -> 23728 bytes packages/parity/src/app-extensions.test.ts | 389 ++++++++++++++++++ packages/parity/src/cases.ts | 42 ++ packages/parity/src/desktop.test.ts | 2 +- packages/parity/src/hooks.test.ts | 14 + packages/parity/src/vite-plugin.test.ts | 39 ++ .../src/components/Avatar/Avatar.stories.tsx | 12 + .../react/src/components/Avatar/Avatar.tsx | 5 +- .../src/components/Card/Card.stories.tsx | 26 ++ packages/react/src/components/Card/Card.tsx | 7 +- .../src/components/Code/Code.stories.tsx | 19 + packages/react/src/components/Code/Code.tsx | 20 + .../ContextMenu/ContextMenu.stories.tsx | 7 +- .../components/ContextMenu/ContextMenu.tsx | 17 +- .../src/components/Dialog/Dialog.stories.tsx | 91 +++- .../react/src/components/Dialog/Dialog.tsx | 30 +- .../NativeSelect/NativeSelect.stories.tsx | 4 +- .../components/NativeSelect/NativeSelect.tsx | 29 +- .../NumberInput/NumberInput.stories.tsx | 35 +- .../components/NumberInput/NumberInput.tsx | 40 +- .../components/Progress/Progress.stories.tsx | 3 +- .../src/components/Progress/Progress.tsx | 9 +- .../src/components/RadioGroup/RadioGroup.tsx | 32 +- .../SegmentedControl/SegmentedControl.tsx | 27 +- .../src/components/Select/Select.stories.tsx | 29 ++ .../react/src/components/Select/Select.tsx | 28 +- .../react/src/components/Stepper/Stepper.tsx | 5 +- .../src/components/Tabs/Tabs.stories.tsx | 40 ++ packages/react/src/components/Tabs/Tabs.tsx | 62 ++- .../src/components/Wizard/Wizard.stories.tsx | 55 +++ .../react/src/components/Wizard/Wizard.tsx | 37 +- packages/react/src/hooks/Hooks.stories.tsx | 25 +- packages/react/src/hooks/index.ts | 8 +- packages/react/src/hooks/media.ts | 17 +- packages/react/src/index.ts | 17 +- packages/react/typecheck/NonEmptyValues.tsx | 44 ++ packages/recipes/src/a11y.ts | 46 +++ packages/recipes/src/card.ts | 20 + packages/recipes/src/config.ts | 4 + packages/recipes/src/index.ts | 4 + packages/recipes/src/native/data.ts | 2 + packages/recipes/src/native/display.ts | 2 + packages/recipes/src/navigation.ts | 9 +- packages/recipes/src/tv.ts | 4 +- packages/recipes/src/utils.ts | 29 ++ packages/recipes/src/vite.ts | 21 +- packages/tokens/scripts/build-theme.ts | 6 + packages/tokens/src/index.ts | 1 + packages/tokens/src/resolve.ts | 59 +++ packages/tokens/test/theme.test.ts | 9 + .../src/components/Avatar/Avatar.stories.ts | 14 + packages/vue/src/components/Avatar/Avatar.vue | 4 +- .../vue/src/components/Card/Card.stories.ts | 25 ++ packages/vue/src/components/Card/Card.vue | 8 +- .../vue/src/components/Code/Code.stories.ts | 22 + packages/vue/src/components/Code/Code.vue | 24 ++ .../ContextMenu/ContextMenu.stories.ts | 8 +- .../components/ContextMenu/ContextMenu.vue | 15 +- .../src/components/Dialog/Dialog.stories.ts | 55 ++- packages/vue/src/components/Dialog/Dialog.vue | 14 +- .../src/components/Dialog/DialogContent.vue | 23 +- packages/vue/src/components/Dialog/context.ts | 5 +- .../components/NativeSelect/NativeSelect.vue | 22 +- .../NumberInput/NumberInput.stories.ts | 29 +- .../components/NumberInput/NumberInput.vue | 36 +- .../components/Progress/Progress.stories.ts | 6 +- .../vue/src/components/Progress/Progress.vue | 9 +- .../src/components/RadioGroup/RadioGroup.vue | 26 +- .../SegmentedControl/SegmentedControl.vue | 28 +- .../src/components/Select/Select.stories.ts | 26 ++ packages/vue/src/components/Select/Select.vue | 27 +- .../vue/src/components/Stepper/Stepper.vue | 4 +- .../vue/src/components/Tabs/Tabs.stories.ts | 34 ++ packages/vue/src/components/Tabs/Tabs.vue | 18 +- .../vue/src/components/Tabs/TabsTrigger.vue | 47 ++- packages/vue/src/components/Tabs/context.ts | 4 + .../src/components/Wizard/Wizard.stories.ts | 42 ++ packages/vue/src/components/Wizard/Wizard.vue | 45 +- packages/vue/src/composables/Hooks.stories.ts | 23 +- packages/vue/src/composables/index.ts | 8 +- packages/vue/src/composables/media.ts | 30 +- packages/vue/src/index.ts | 17 +- packages/vue/typecheck/NonEmptyValues.vue | 51 +++ 116 files changed, 2517 insertions(+), 241 deletions(-) create mode 100644 .changeset/compact-display.md create mode 100644 .changeset/context-menu-keyboard.md create mode 100644 .changeset/dialog-focus.md create mode 100644 .changeset/non-empty-values.md create mode 100644 .changeset/number-input-empty-fallback.md create mode 100644 .changeset/positioner-z-index.md create mode 100644 .changeset/resolve-tokens.md create mode 100644 .changeset/select-empty-value.md create mode 100644 .changeset/tabs-panels-elsewhere.md create mode 100644 .changeset/vite-plugin-dev-imports.md create mode 100644 .changeset/wizard-texts.md create mode 100644 packages/parity/e2e/visual/__screenshots__/native-data-avatar--own-initials.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-data-avatar--own-initials.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-data-card--small.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-data-code--playground.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-forms-numberinput--empty-fallback.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-forms-select--empty-string-value.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-forms-wizard--own-texts.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-navigation-tabs--panels-elsewhere.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-overlays-dialog--own-steps.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-overlays-dialog--selected-on-open.png create mode 100644 packages/parity/e2e/visual/__screenshots__/react-utilities-hooks--resolved-tokens.png create mode 100644 packages/parity/src/app-extensions.test.ts create mode 100644 packages/react/src/components/Code/Code.stories.tsx create mode 100644 packages/react/src/components/Code/Code.tsx create mode 100644 packages/react/typecheck/NonEmptyValues.tsx create mode 100644 packages/tokens/src/resolve.ts create mode 100644 packages/vue/src/components/Code/Code.stories.ts create mode 100644 packages/vue/src/components/Code/Code.vue create mode 100644 packages/vue/src/components/Tabs/context.ts create mode 100644 packages/vue/typecheck/NonEmptyValues.vue diff --git a/.changeset/compact-display.md b/.changeset/compact-display.md new file mode 100644 index 0000000..ded9e8c --- /dev/null +++ b/.changeset/compact-display.md @@ -0,0 +1,19 @@ +--- +'@wirecore/wirelayer-recipes': minor +'@wirecore/wirelayer-css': minor +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +'@wirecore/wirelayer-native': minor +--- + +Display parts for dense desktop UIs, all opt-in: + +- `Progress status="neutral"`: a grey fill, e.g. for a cancelled transfer (also React Native) +- `Avatar initials`: own initials instead of the ones from `name` ("MS" for "my-server"; also React Native) +- `Card size="sm"`: smaller radius, padding and title, for lists of cards inside a dialog (`wc-card-sm` in HTML); + `ConfigProvider size` leaves cards alone +- `Code` in the main package: code in a sentence with the look of `InlineCode`, without syntax colours and without the + optional `shiki` + +`rounded-card` and `rounded-panel` now count as radii for tailwind-merge (like `rounded-popup`): another radius in +`class`/`classes` replaces them instead of both classes ending up on the element. diff --git a/.changeset/context-menu-keyboard.md b/.changeset/context-menu-keyboard.md new file mode 100644 index 0000000..89336ea --- /dev/null +++ b/.changeset/context-menu-keyboard.md @@ -0,0 +1,10 @@ +--- +'@wirecore/wirelayer-recipes': minor +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +--- + +`ContextMenu`'s `before-open` / `onBeforeOpen` gets a second argument `{ source: 'pointer' | 'keyboard', focused }`: +opened with Shift+F10 or the menu key, `focused` is the element with the focus – a list can tell a row from its empty +space even when the browser fires the event on the whole area. The first argument stays as it was. +`createContextMenuSource()` in the recipes tells the two apart. diff --git a/.changeset/dialog-focus.md b/.changeset/dialog-focus.md new file mode 100644 index 0000000..1c8e07c --- /dev/null +++ b/.changeset/dialog-focus.md @@ -0,0 +1,15 @@ +--- +'@wirecore/wirelayer-recipes': minor +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +--- + +Focus for dialogs and wizards whose content changes: + +- `data-autoselect` on a field in a `Dialog`: it gets the focus when the dialog opens, with its text selected (e.g. a + prefilled name). Without it Ark's choice stays (an alertdialog's close button, `data-autofocus`, the first element) +- `focusFirst(container)` (also from the component packages) focuses like opening does – `data-autoselect`, + `data-autofocus`/`autofocus`, otherwise the first field; Vue's `DialogContent` exposes it as `focusFirst()` +- `Wizard focusOnStepChange` moves the focus into each new step the same way (not after a failed check: the invalid + field keeps it) +- `autoselectElement(content)` in the recipes for a dialog's own initial focus diff --git a/.changeset/non-empty-values.md b/.changeset/non-empty-values.md new file mode 100644 index 0000000..0894963 --- /dev/null +++ b/.changeset/non-empty-values.md @@ -0,0 +1,13 @@ +--- +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +'@wirecore/wirelayer-native': minor +--- + +Choices that cannot be empty give their value type without the empty value – no `$event && …` guard for a model that +is never empty. Only the types change, unless noted: + +- `RadioGroup` and `SegmentedControl` with `required`: v-model / `onValueChange` get `V`, not `V | null`. A form reset + back to an empty start (the only way Ark empties the group) is no longer passed on for a required group. + `SegmentedControl` takes `required` now (`aria-required` on the group); React Native's `RadioGroup` is typed the same +- `NativeSelect` without `placeholder`: `V`, not `V | ''` (only the placeholder option gives `''`) diff --git a/.changeset/number-input-empty-fallback.md b/.changeset/number-input-empty-fallback.md new file mode 100644 index 0000000..b898776 --- /dev/null +++ b/.changeset/number-input-empty-fallback.md @@ -0,0 +1,11 @@ +--- +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +--- + +`NumberInput` with `emptyFallback`, for fields that always need a number (e.g. a port): an emptied field keeps its value +while it is empty and falls back to `min` (or 0) or to the last valid number (`previous`) when it is left or Enter is +pressed. v-model / `onValueChange` is then typed `number`. Without it an emptied field still reports null. + +Note for type code around the components: `NumberInput` (Vue) is generic now, so `InstanceType` and +Storybook's `Meta` need a cast (as for Select). diff --git a/.changeset/positioner-z-index.md b/.changeset/positioner-z-index.md new file mode 100644 index 0000000..9851f4c --- /dev/null +++ b/.changeset/positioner-z-index.md @@ -0,0 +1,9 @@ +--- +'@wirecore/wirelayer-tokens': patch +'@wirecore/wirelayer-vue': patch +'@wirecore/wirelayer-react': patch +--- + +Popups with a positioner (Select, Combobox, Menu, Popover …) stay above a dialog: the theme gives Ark's positioners +`z-index: var(--z-index)` (in the base layer). Ark sets the z-index inline, but Vue removed it again whenever the popup +rendered anew – the list then lay behind the dialog, looked closed and the pointer hit the dialog. diff --git a/.changeset/resolve-tokens.md b/.changeset/resolve-tokens.md new file mode 100644 index 0000000..e505032 --- /dev/null +++ b/.changeset/resolve-tokens.md @@ -0,0 +1,10 @@ +--- +'@wirecore/wirelayer-tokens': minor +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +--- + +Token colours as fixed values for canvas libraries (xterm.js, charts) that cannot read CSS variables: +`resolveTokens(element?)` gives the colours that apply right now (mode, theme and own overrides) as hex – `#rrggbbaa` for +the translucent ones – and `watchTokens(onChange)` reports a change of mode, theme or system colour scheme. The +composable/hook `useResolvedTokens()` keeps them up to date. diff --git a/.changeset/select-empty-value.md b/.changeset/select-empty-value.md new file mode 100644 index 0000000..a3a70fa --- /dev/null +++ b/.changeset/select-empty-value.md @@ -0,0 +1,8 @@ +--- +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +--- + +`Select` with `emptyValue`, e.g. `""` for a model that uses an empty string for "nothing chosen": the value counts as +null – the placeholder shows, no development warning about an unknown value – and clearing gives it back instead of null +(typed `V | E`). Without it `""` is still an unknown value shown as text. diff --git a/.changeset/tabs-panels-elsewhere.md b/.changeset/tabs-panels-elsewhere.md new file mode 100644 index 0000000..a38c9b7 --- /dev/null +++ b/.changeset/tabs-panels-elsewhere.md @@ -0,0 +1,10 @@ +--- +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +--- + +`Tabs` for panels that live elsewhere (editor or terminal tabs, panes shown with `v-show`): + +- `TabsTrigger controls="pane-id"` points `aria-controls` at that element; `null` removes it (tabs without panels) +- `Tabs` takes `null` as its value: no tab selected. The first enabled tab then stays reachable with Tab (Ark takes + every tab out of the Tab order without a selection); Tabs without a value stay as they were diff --git a/.changeset/vite-plugin-dev-imports.md b/.changeset/vite-plugin-dev-imports.md new file mode 100644 index 0000000..145fbed --- /dev/null +++ b/.changeset/vite-plugin-dev-imports.md @@ -0,0 +1,8 @@ +--- +'@wirecore/wirelayer-recipes': patch +--- + +Vite plugin: a component imported for the first time while `vite dev` runs gets its classes right away. The plugin added +its sources file to the CSS, but Tailwind's Vite plugin only builds a CSS file again when a file it imports changes on +disk – the classes were missing until a restart (cards without padding, tiny avatars). In the dev server the imports now +live in a file in Vite's cache folder; the build is unchanged. diff --git a/.changeset/wizard-texts.md b/.changeset/wizard-texts.md new file mode 100644 index 0000000..81ebf1b --- /dev/null +++ b/.changeset/wizard-texts.md @@ -0,0 +1,8 @@ +--- +'@wirecore/wirelayer-vue': minor +'@wirecore/wirelayer-react': minor +--- + +Own texts in `Wizard` (and `Stepper`): `finishLabel` names the last button (default "Finish"), and a step's `validate` +may return a text (also async) – it keeps the step open and is shown as its error instead of "Please check the entries +in this step". diff --git a/apps/storybook/src/Migration.mdx b/apps/storybook/src/Migration.mdx index 3c96cff..1ea8bb0 100644 --- a/apps/storybook/src/Migration.mdx +++ b/apps/storybook/src/Migration.mdx @@ -74,3 +74,19 @@ by side, then the places where the two behave differently. The React package has numbers); `Checkbox` with a boolean model gives booleans. - **Compact parts:** `Progress size="xs"` with `hide-label`, `bar-text` and `status`; `Alert #actions`; `mono` on text fields; `DialogContent title-position="header"` puts the title next to the close button. +- **Values that are never empty:** with `required`, `RadioGroup` and `SegmentedControl` give v-model `V` instead of + `V | null`; `NativeSelect` without `placeholder` gives `V` instead of `V | ''`; `NumberInput empty-fallback="min"` or + `"previous"` refills an emptied field when it is left (typed `number`). `Select empty-value=""` treats an empty string + like null – the placeholder shows, clearing gives `""` back. +- **Focus in dialogs and wizards:** `data-autoselect` focuses a field and selects its text when the dialog opens (e.g. a + prefilled name). For content that changes while it is open, `DialogContent` exposes `focusFirst()` (React: + `focusFirst(element)`), and `Wizard focus-on-step-change` moves the focus into each new step. `Wizard finish-label` + names the last button; `validate` may return a text, shown as the step's error. +- **Panels elsewhere:** `TabsTrigger controls="pane-id"` points a tab at a pane outside the Tabs (`:controls="null"` + for none); `Tabs` takes `null` for "no tab selected" – the first tab stays reachable with Tab. +- **Context menus by keyboard:** `@before-open` gets a second argument `{ source: 'pointer' | 'keyboard', focused }` – + with Shift+F10 or the menu key `focused` is the element with the focus (e.g. the list row). +- **Canvas libraries** (xterm.js, charts): `resolveTokens()` gives the token colours of the current mode and theme as hex; + `useResolvedTokens()` updates when the mode, the theme or the system colour scheme changes. +- **More compact parts:** `Card size="sm"` for lists in dialogs, `Progress status="neutral"` for cancelled work, + `Avatar initials="MS"`, and `Code` for code in a sentence without shiki (the look of `InlineCode` from `/code`). diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md index 4e1f659..3540757 100644 --- a/docs/implementation-notes.md +++ b/docs/implementation-notes.md @@ -152,6 +152,24 @@ Worth reading before changing the affected areas. or an effect (and React's `autoFocus`, which only calls `focus()`) is overridden. The trap looks for `[data-autofocus]`/`[autofocus]`; an `alertdialog` starts on its close button. In jsdom no field counts as focusable (no layout), so the test runs in the browser. +- **Positioner z-index in Vue:** Ark copies the z-index of a popup's content to its positioner as an inline style + (`z-index: var(--z-index)`), after the render. Vue patches the positioner's `style` object on every render and clears + keys that are null there – `zIndex` is one of them, so the inline z-index was gone after the first update and the list + lay behind a dialog. The theme sets the same z-index for `[data-scope][data-part='positioner']` in the base layer + (a z-index utility on a positioner still wins). +- **`data-autoselect` and the alertdialog:** the Dialog passes its own `initialFocusEl` to find the marked field – that + replaces Ark's default, which starts an `alertdialog` on its close button, so the Dialog takes the close button itself + for an alertdialog without a marked field. +- **Tabs without a selected tab:** Ark makes only the selected tab tabbable – with the value null no tab is in the Tab + order. When the value is null on purpose, the first enabled tab gets `tabIndex` 0 (found in the DOM after rendering); + Tabs without a value (uncontrolled, no default) stay as they were. +- **Tailwind's Vite plugin and added imports:** `@tailwindcss/vite` keeps the compiled CSS of a file until one of the + files it imports changes on disk – imports that a plugin adds to the code alone are ignored. In the dev server the + WireLayer plugin therefore writes them to a file in Vite's cache folder and imports that file. +- **Narrower emit types:** `required` (RadioGroup, SegmentedControl), the placeholder (NativeSelect), `emptyFallback` + (NumberInput) and `emptyValue` (Select) are extra generics that only change the type of v-model/onValueChange. + `emptyValue` is `NoInfer` in the value types – otherwise the bound value would be taken as `E` and remove null from + the type even without `emptyValue`. - **Controlled form elements:** Vue additionally writes the value of a `` does not know), React only sets the property – the DOM comparison ignores this attribute. For `