WIP: React Native modal fixes #1
@@ -126,7 +126,8 @@ serves as a reference for the implemented components and their locations.
|
||||
| Scrollbar Styling | `scrollbar.css` (`.dt-scrollbar`) | -- | Thin scrollbar with mode-normal color |
|
||||
| Colour Vision Palettes | Generated `[data-cb="protanopia\|deuteranopia\|tritanopia"]` blocks in `tokens/dist/css/*.css` | `DTWebThemeProvider` (`colorVision` prop) | Palettes live in `tokens/src/colorVision.ts`; the tokens build fails if a palette drops under ΔE 25 or 4.5:1 (`npm run cb-check -w packages/tokens`). Dark mode only. |
|
||||
| Animation Speed | `--motion-scale` + `[data-motion="0"]` in `animations.css` | `DTWebThemeProvider` (`motionScale` prop) | Classic brand bypasses animation regardless (`BrandTokens.animations`) |
|
||||
| Settings Panel | -- | `DTSettingsPanel` (react and react-native) | Theme / animation speed / colour vision; controlled, no persistence |
|
||||
| Settings Panel | -- | `DTSettingsPanel` (react and react-native) | Theme / animation speed / still images / colour vision; controlled, no persistence |
|
||||
| Still Images | `data-still` on the root (web) | `DTMotionImage`, `stillImages` provider prop | GIF timing can't be scaled, so motion-sensitive viewers get a single frame; forced on at motion 0 and on classic |
|
||||
|
||||
**Card color modes reference:**
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function App() {
|
||||
|
||||
### Settings: theme, animation speed, colour vision
|
||||
|
||||
Both providers accept `colorVision` (`none` | `protanopia` | `deuteranopia` | `tritanopia`) and `motionScale` (1 = as designed, 0 = off; the classic brand never animates). `DTSettingsPanel` renders the standard controls; it is controlled, so persist the values however your app does.
|
||||
Both providers accept `colorVision` (`none` | `protanopia` | `deuteranopia` | `tritanopia`), `motionScale` (1 = as designed, 0 = off; the classic brand never animates) and `stillImages` (animated pictures render a single frame; forced on at motion 0). `DTMotionImage` takes an animated source plus a `still` and picks the right one. `DTSettingsPanel` renders the standard controls; it is controlled, so persist the values however your app does.
|
||||
|
||||
```tsx
|
||||
import { DTWebThemeProvider, DTSettingsPanel } from "@dangerousthings/react";
|
||||
@@ -150,11 +150,11 @@ npm run release # build + publish to npm
|
||||
|
||||
The `@dangerousthings/react` package provides React web components that wrap the CSS from `@dangerousthings/web`. The component API mirrors the React Native package for consistency:
|
||||
|
||||
DTAccordion, DTBadgeOverlay, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTFeatureLegend, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTMobileFilterOverlay, DTModal, DTProgressBar, DTQuantityStepper, DTRadioGroup, DTSearchInput, DTSettingsPanel, DTStaggerContainer, DTSwitch, DTTextInput
|
||||
DTAccordion, DTBadgeOverlay, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTFeatureLegend, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTMobileFilterOverlay, DTModal, DTProgressBar, DTQuantityStepper, DTMotionImage, DTRadioGroup, DTSearchInput, DTSettingsPanel, DTStaggerContainer, DTSwitch, DTTextInput
|
||||
|
||||
## React Native Components
|
||||
|
||||
DTAccordion, DTBadgeOverlay, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTFeatureLegend, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTMobileFilterOverlay, DTModal, DTProgressBar, DTQuantityStepper, DTRadioGroup, DTSearchInput, DTSettingsPanel, DTStaggerContainer, DTSwitch, DTTextInput
|
||||
DTAccordion, DTBadgeOverlay, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTFeatureLegend, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTMobileFilterOverlay, DTModal, DTProgressBar, DTQuantityStepper, DTMotionImage, DTRadioGroup, DTSearchInput, DTSettingsPanel, DTStaggerContainer, DTSwitch, DTTextInput
|
||||
|
||||
**Animation hooks:** `useScaleIn`, `usePulse` (both honour `motionScale`)
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# DTMultiSelect and DTSelect Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add the design system's first selection panels: `DTMultiSelect` (pick several options) and `DTSelect` (pick one), across React Native, web CSS and React, released as 0.7.0 and adopted by MIDI Commander.
|
||||
|
||||
**Architecture:** A docked panel that is part of the layout, not an overlay: an always-visible summary header (label, count, selection summary) that expands the list of rows in place when tapped and collapses it again, inside a complete outline. Apps dock it where it should stay on screen (MIDI Commander: at the bottom of the home screen, below the scrolling content). Rows reuse DTCheckbox's box. Selection rules (limits, select all, summary text) live in a small pure module so they are tested once per package, not per framework.
|
||||
|
||||
**Tech Stack:** React Native Paper, react-native-svg (`buildBeveledRectPath`), `@dangerousthings/tokens` theme values, web CSS in `packages/web/src/components/forms-dt.css`, React for the web wrapper, Changesets, vitest (new; pure logic only).
|
||||
|
||||
**Design:** proposal with the presentations considered, states, anatomy and API: https://claude.ai/artifact/DRjeqsmWpYPWJkPvy4z62h
|
||||
|
||||
---
|
||||
|
||||
## Decisions (2026-09-15)
|
||||
|
||||
- **Presentation: docked summary that expands in place** (proposal option B). The summary header is on screen the whole time; tapping it grows the panel upward to show the list, and tapping again collapses it. Collapsed by default. No scrim or overlay. Considered and not chosen: an anchored dropdown (A), a modal bottom sheet, a permanently expanded list (too tall for what should be a summary), and chips in a trigger (C).
|
||||
- The component does not position itself. It renders in the flow; the app places it (for a docked panel, outside and below its `ScrollView`, above the bottom safe-area inset).
|
||||
- `DTSelect` (single choice) ships in the same release: MIDI Commander's note editor needs it for its track picker.
|
||||
- ALL appears in the header only while expanded, disabled when everything is already selected; it stays when `minSelected` is set, because selecting everything never breaks a minimum.
|
||||
- `minSelected` locks the last selected rows (disabled) and the hint says why ("At least 1 track stays on."). `maxSelected` disables unselected rows.
|
||||
- No new tokens: every value comes from the existing theme (accent per `variant`, surface, background, border, bevel sizes, radii).
|
||||
|
||||
## Component contract
|
||||
|
||||
```ts
|
||||
export interface DTSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
/** Right-aligned detail, e.g. "565 NOTES". */
|
||||
meta?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface DTMultiSelectProps {
|
||||
options: DTSelectOption[]
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
label: string
|
||||
placeholder?: string
|
||||
/** Summary noun: "TRACK" gives "3 TRACKS" and "ALL TRACKS". */
|
||||
itemNoun?: string
|
||||
minSelected?: number
|
||||
maxSelected?: number
|
||||
showSelectAll?: boolean // default true
|
||||
/** App actions in the header before ALL, e.g. MIDI Commander's "+ ADD" (compact DTButtons). */
|
||||
headerActions?: ReactNode
|
||||
/** Rows shown before the list scrolls; default 5.5 (a cut-off row signals more). */
|
||||
visibleRows?: number
|
||||
/** Controlled expansion; uncontrolled (starting collapsed) when omitted. */
|
||||
expanded?: boolean
|
||||
onExpandedChange?: (expanded: boolean) => void
|
||||
/** Appended to the summary, e.g. "977 NOTES". */
|
||||
summaryDetail?: string
|
||||
helperText?: string
|
||||
error?: string
|
||||
variant?: DTVariant
|
||||
disabled?: boolean
|
||||
style?: StyleProp<ViewStyle> // CSSProperties / className on web
|
||||
}
|
||||
|
||||
export interface DTSelectProps
|
||||
extends Omit<DTMultiSelectProps, 'value' | 'onChange' | 'minSelected' | 'maxSelected' | 'showSelectAll'> {
|
||||
value: string | null
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
**DT anatomy.** Outline: 2 dp accent border with a 5 dp top band (as DTMenu) and 18 dp bevelled bottom corners (as DTCard), drawn as a filled accent shape under an inset bevelled fill, not a stroke clipped by the bevel, or the diagonals lose their edge. Header, on two lines so nothing truncates at phone width: label and count on the first, summary on the second at full width, a chevron pointing up while collapsed, header actions on the right, and ALL only while expanded. Label, count, summary and chevron are one pressable toggle. Label 14 dp / 700 / letter-spacing 1.6 in the accent; summary (placeholder when empty, 1–2 labels joined by commas, "3 TRACKS" beyond that, "ALL TRACKS" when everything is selected, ellipsised) in `textSecondary`; count "2/3" in the accent with tabular figures; ALL as a compact outlined DTButton; 1 dp divider below. Expanding animates the list height with `Animated` scaled by `useDTMotionScale()` (none at 0). Rows (only while expanded): 48 dp, DTCheckbox box (20 dp, 30 % bevels top-left and bottom-right), uppercase label, optional meta on the right; selected rows tint 10 % accent; list scrolls after `visibleRows`. `DTSelect` rows have no checkbox and mark the chosen row like DTMenu's active item (70 % accent fill).
|
||||
|
||||
**Classic anatomy.** Classic is not the DT drawing recoloured; it follows its own tokens and the way `bevels.css` / `forms-dt.css` already treat Classic cards, buttons, badges and menu items (`bevelMd === 0` in React Native):
|
||||
|
||||
- Outline: 1 px `border` (slate `#1e293b`), `radiusLg` corners (12 px outer, 11 px inner), no top band; error state switches the outline to the error colour.
|
||||
- Header: label with lighter tracking, summary in `textSecondary`, chevron in `textSecondary` (accent while expanded), count as a pill (accent text on 14 % accent, fully rounded), ALL as a Classic DTButton (`radius`, 2 px top border); divider in `border`.
|
||||
- Rows separated by `border`; pressed `surfaceHover`; selected rows tint 8 % accent.
|
||||
- Checkbox: `radiusSm` (4 px) rounded square, `textSecondary` outline, filled accent when checked.
|
||||
- System sans from `--font-sans`; no motion.
|
||||
|
||||
Accessibility: the header toggle is a button with `accessibilityState.expanded` that controls the list; the panel is a group labelled by it; rows are checkboxes (`accessibilityRole="checkbox"`, checked and disabled states) for DTMultiSelect and radio buttons for DTSelect; the summary is announced politely when it changes; web adds arrow-key roving focus and Space to toggle.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Test setup and selection logic
|
||||
|
||||
**Files:** Create `packages/react-native/src/utils/selection.ts`, `selection.test.ts`; same pair in `packages/react/src/utils/`; add `vitest` dev dependency and `"test": "vitest run"` to both packages; add a `test` task to `turbo.json`.
|
||||
|
||||
The repo has no test framework yet (see CLAUDE.md Testing). Start with pure logic only, next to source as the conventions say. The module is small enough to keep one copy per package rather than invent a shared package for it.
|
||||
|
||||
- `toggleValue(options, current, value, { min, max }) → string[]`: removal refused at `min`, addition refused at `max`, result kept in option order, disabled options untouchable.
|
||||
- `selectAll(options, current, { max }) → string[]`
|
||||
- `summaryText(options, value, { placeholder, itemNoun }) → { text, placeholder: boolean }`
|
||||
- `isLocked(option, value, { min, max }) → boolean` for row disabling.
|
||||
|
||||
Tests first: every rule above, empty options, a single option ("ALL" wording not used for one), plural noun.
|
||||
|
||||
### Task 2: React Native panel frame (internal)
|
||||
|
||||
**Files:** Create `packages/react-native/src/components/DTSelectPanel.tsx` (not exported).
|
||||
|
||||
The outline and header shared by both components. DT: build the frame the way DTCard does, not as one full-height SVG. DTCard documents that react-native-svg draws a tall `Svg` progressively lower the taller it is (≈3 px at 1200 dp on a Pixel 7a), so straight edges and the top band are absolutely positioned Views and SVG is used only for the two small bevelled bottom corners (background layer under content, border band over it). Classic (`theme.custom.bevelMd === 0`): a bordered View with `radiusLg`. Header: the pressable toggle (label, count, summary with `summaryDetail`, chevron), `headerActions`, and ALL while expanded; expansion controlled (`expanded` / `onExpandedChange`) or uncontrolled. Colours only from `useDTTheme()` and `getVariantColor`.
|
||||
|
||||
### Task 3: DTMultiSelect (React Native)
|
||||
|
||||
**Files:** Create `packages/react-native/src/components/DTMultiSelect.tsx`; export from `src/index.ts`.
|
||||
|
||||
Panel frame from Task 2; while expanded, a list whose rows are `Pressable` (expansion animated with `LayoutAnimation` for 200 ms × `useDTMotionScale()`, skipped at 0, rather than a measured `Animated` height) with the DTCheckbox box, label and meta; a `ScrollView` capped at `visibleRows` × 48 dp (nested scrolling enabled, so it works inside a screen that also scrolls). Hint / error text at the bottom of the panel. `accessibilityRole`/`accessibilityState` per the contract.
|
||||
|
||||
### Task 4: DTSelect (React Native)
|
||||
|
||||
**Files:** Create `packages/react-native/src/components/DTSelect.tsx`; export from `src/index.ts`.
|
||||
|
||||
Same frame and list; rows without checkboxes, chosen row filled at the 70 % accent like DTMenu's active item; no ALL.
|
||||
|
||||
### Task 5: DTButton fills its row (carried from MIDI Commander)
|
||||
|
||||
**Files:** Modify `packages/react-native/src/components/DTButton.tsx`.
|
||||
|
||||
MIDI Commander patches this today (`patches/@dangerousthings+react-native+0.5.1.patch`): buttons sharing a row draw at different heights when one label wraps, because the Pressable stretches but the inner drawn box does not. Give the container and content `flexGrow: 1`, keep string labels on one line (`numberOfLines={1}`, `adjustsFontSizeToFit`, `minimumFontScale={0.6}`) and reduce side padding from 24 to 8. Check the showcase's button rows and every existing screen that uses full-width buttons. The panel header's compact ALL button depends on it.
|
||||
|
||||
### Task 6: Web CSS
|
||||
|
||||
**Files:** Modify `packages/web/src/components/forms-dt.css`.
|
||||
|
||||
`.dt-multiselect` / `.dt-select`: `__outline` (accent layer + bevelled inner fill, clip-paths), `__header`, `__label`, `__summary`, `__count`, `__all`, `__list`, `__row` (`[aria-checked="true"]`, `:disabled`), `__hint`, `--error` and `--disabled` modifiers; the row checkbox uses the existing `input[type="checkbox"]` bevel rules. Both `[data-brand="dt"]` and `[data-brand="classic"]`, custom properties only.
|
||||
|
||||
### Task 7: React web components
|
||||
|
||||
**Files:** Create `packages/react/src/components/DTMultiSelect.tsx`, `DTSelect.tsx`; export from `packages/react/src/index.ts`.
|
||||
|
||||
A `fieldset`-like group labelled by the header; rows are native checkboxes / radios styled by Task 6; arrow keys move focus, Space toggles or picks. Uses the Task 1 logic.
|
||||
|
||||
### Task 8: Showcase
|
||||
|
||||
**Files:** Modify `packages/showcase/mobile/src/screens/FormsScreen.tsx`, `packages/showcase/desktop/src/renderer/pages/FormsPage.tsx`.
|
||||
|
||||
Every state from the proposal (collapsed, expanded, empty, all, at minimum, long list, error, disabled) for both components, under both brands and a colour-vision palette, plus a docked example at the bottom of a scrolling screen.
|
||||
|
||||
### Task 9: Release
|
||||
|
||||
`npx changeset` (minor: react-native, react, web), `npm run build`, `npm run typecheck`, `turbo run test`, then the parity checklist in CLAUDE.md. Publishes 0.7.0.
|
||||
|
||||
### Task 10: Adopt in MIDI Commander
|
||||
|
||||
**Repo:** `/home/work/VSCodeProjects/MIDI Commander`.
|
||||
|
||||
Vendor the new `@dangerousthings/react-native` tarball (and tokens, if 0.6.0+ requires a newer one), delete the DTButton patch, and run its release build on the Pixel 7a. On the home screen, replace the MIDI card's track toggle row with a `DTMultiSelect` (label TRACKS, `itemNoun="track"`, `minSelected={1}`, note counts as `meta`) docked below the `ScrollView`, above the bottom inset, collapsed to its summary (note total as `summaryDetail`) whenever there is a transcription. In the note editor, replace the track buttons with `DTSelect`. The panel's `headerActions` holds MIDI Commander's **+ ADD**: it asks whether to import a MIDI file (its tracks join the list) or record audio (microphone permission, record, then Basic Pitch transcribes it into a new track). That flow, and track deletion and projects, belong to MIDI Commander's own plan, not this one. Recheck the piano-roll toolbar heights once the patch is gone.
|
||||
Generated
+386
-7
@@ -6096,6 +6096,17 @@
|
||||
"@types/responselike": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -6106,6 +6117,13 @@
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -6388,6 +6406,119 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
@@ -6813,6 +6944,16 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
@@ -7856,6 +7997,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -9419,6 +9570,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -9581,6 +9739,16 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -9619,6 +9787,16 @@
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expo": {
|
||||
"version": "54.0.33",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
|
||||
@@ -11917,6 +12095,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-fetch-happen": {
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz",
|
||||
@@ -12902,6 +13090,20 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
@@ -13260,6 +13462,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pe-library": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
|
||||
@@ -14709,6 +14918,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
@@ -14964,6 +15180,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackframe": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||
@@ -15001,6 +15224,13 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stream-buffers": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
|
||||
@@ -15496,6 +15726,23 @@
|
||||
"integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
|
||||
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -15541,6 +15788,16 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
||||
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
@@ -16121,6 +16378,109 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vlq": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz",
|
||||
@@ -16222,6 +16582,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||
@@ -16468,7 +16845,7 @@
|
||||
},
|
||||
"packages/hex-background": {
|
||||
"name": "@dangerousthings/hex-background",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
@@ -16620,7 +16997,7 @@
|
||||
},
|
||||
"packages/react": {
|
||||
"name": "@dangerousthings/react",
|
||||
"version": "1.0.0",
|
||||
"version": "2.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@dangerousthings/tokens": "*",
|
||||
@@ -16629,7 +17006,8 @@
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"typescript": "^5.8.0"
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -16643,7 +17021,7 @@
|
||||
},
|
||||
"packages/react-native": {
|
||||
"name": "@dangerousthings/react-native",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dangerousthings/tokens": "*"
|
||||
@@ -16654,7 +17032,8 @@
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-svg": "^15.15.3",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -17143,7 +17522,7 @@
|
||||
},
|
||||
"packages/tokens": {
|
||||
"name": "@dangerousthings/tokens",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.3",
|
||||
@@ -17155,7 +17534,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@dangerousthings/web",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dangerousthings/tokens": "*"
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"start:desktop": "npm run start -w @dangerousthings/showcase-desktop",
|
||||
"clean": "turbo run clean",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"lint": "turbo run lint",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# @dangerousthings/react-native
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- `DTSelect` and `DTMultiSelect`: docked selection panels with an always-visible summary header that expand their rows in place, on both platforms.
|
||||
|
||||
- react-native: `DTMultiSelect` (checkbox rows, limits, select all, summary text from a tested `selection` module), `DTSelect` (single choice on the same frame), `DTSelectPanel` frame built like `DTCard`. `DTButton` now fills its row and keeps string labels on one line, replacing the patch MIDI Commander carried.
|
||||
- react: `DTSelect` and `DTMultiSelect` over native radios and checkboxes, arrow-key navigation, same selection logic.
|
||||
- web: `.dt-select` / `.dt-multiselect` styles for both brands, with `--error` and `--disabled` modifiers.
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Still-image setting for motion sensitivity. `stillImages` on both providers (also true at motion 0 and on brands that never animate), a switch in `DTSettingsPanel`, and `DTMotionImage`, which renders a still frame instead of an animated source when the setting is on. React Native adds `useDTStillImages`; the web provider stamps `data-still`.
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 28937ea: DTCard: draw the frame with Views instead of full-height SVGs so tall cards render correctly.
|
||||
|
||||
react-native-svg draws a tall `Svg` progressively lower the taller it is (about 3px at 1200dp
|
||||
and 10px at 3800dp on a Pixel 7a), while a plain View lands exactly on its layout box. The
|
||||
card frame, drawn as three full-height SVG overlays, therefore slid down relative to the
|
||||
header on tall cards and opened a black notch in the top-left corner where the border and
|
||||
progress strip should meet the header.
|
||||
|
||||
Every straight edge and the progress strip are now absolutely positioned Views driven by
|
||||
layout, with no measurement dependency, and SVG is used only for the two beveled bottom
|
||||
corners (16–32dp, which cannot drift measurably). The bottom-right corner is split into a
|
||||
background layer under the content and an accent band over it, so content reaching into
|
||||
the bevel is no longer painted over. Adjacent pieces overlap by 1dp under the layers drawn
|
||||
on top to avoid sub-pixel seams. No API change; the classic (non-bevel) branch is untouched.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dangerousthings/react-native",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"description": "React Native themed components for the Dangerous Things design system",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -42,7 +42,8 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dangerousthings/tokens": "*"
|
||||
@@ -65,6 +66,7 @@
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-svg": "^15.15.3",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,9 @@ export function DTButton({
|
||||
{typeof children === 'string' ? (
|
||||
<Text
|
||||
variant="labelLarge"
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.6}
|
||||
style={[styles.label, {color: getTextColor(isPressedState)}]}>
|
||||
{children}
|
||||
</Text>
|
||||
@@ -229,14 +232,19 @@ export function DTButton({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
// `flexGrow`, not `flex: 1`: buttons sharing a row are stretched by the row, but the drawn box only sized to
|
||||
// its label, so a wrapped label in one button left the others drawing short. flexGrow keeps flexBasis auto, so
|
||||
// a button with no height to fill still measures its content.
|
||||
position: 'relative',
|
||||
minHeight: 44,
|
||||
flexGrow: 1,
|
||||
},
|
||||
content: {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flexGrow: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
paddingHorizontal: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildCardBevelPath} from '../utils/bevelPaths';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
|
||||
interface DTCardProps {
|
||||
@@ -96,116 +95,69 @@ interface DTCardProps {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build inner card path with left edge at bevelSizeSmall (for progress bar zone).
|
||||
* 5-point polygon — no bottom-left bevel, straight vertical left edge.
|
||||
* Why the frame is built from Views, not one tall SVG:
|
||||
*
|
||||
* react-native-svg draws a tall `Svg` progressively lower the taller it is (≈3px at
|
||||
* 1200dp, ≈10px at 3800dp on a Pixel 7a) while a plain View lands exactly on its layout
|
||||
* box. A card frame drawn as full-height SVG paths therefore slides down relative to the
|
||||
* header on tall cards and opens a notch in the top-left corner. So every straight edge
|
||||
* and the progress strip are absolutely positioned Views driven by layout (no measuring,
|
||||
* no first-frame gap), and SVG is used only for the two beveled bottom corners, which are
|
||||
* 16–32dp tall and cannot drift measurably.
|
||||
*/
|
||||
function buildInnerCardPath(
|
||||
w: number,
|
||||
h: number,
|
||||
bevelBR: number,
|
||||
bw: number,
|
||||
bevelSizeSmall: number,
|
||||
): string {
|
||||
const right = w - bw;
|
||||
const top = bw;
|
||||
const bottom = h - bw;
|
||||
const br = Math.min(
|
||||
bevelBR - bw,
|
||||
(right - bevelSizeSmall) / 3,
|
||||
(bottom - top) / 3,
|
||||
|
||||
/**
|
||||
* Bottom-right corner, in two layers so content is never painted over: the card background
|
||||
* inside the bevel goes UNDER the content, and only the accent border band goes over it.
|
||||
* Outside the outer diagonal nothing is painted, as before.
|
||||
*/
|
||||
function CornerBottomRightBg({size, bw, bg}: {size: number; bw: number; bg: string}) {
|
||||
const s = size;
|
||||
const i = s - bw;
|
||||
return (
|
||||
<Svg pointerEvents="none" style={[styles.abs, {right: 0, bottom: 0}]} width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
||||
<Path d={`M 0 0 L ${i} 0 L 0 ${i} Z`} fill={bg} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
function CornerBottomRightBorder({size, bw, accent}: {size: number; bw: number; accent: string}) {
|
||||
const s = size;
|
||||
const i = s - bw;
|
||||
return (
|
||||
<Svg pointerEvents="none" style={[styles.abs, {right: 0, bottom: 0, zIndex: 2}]} width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
||||
<Path d={`M ${i} 0 L ${s} 0 L 0 ${s} L 0 ${i} Z`} fill={accent} />
|
||||
</Svg>
|
||||
);
|
||||
if (right <= bevelSizeSmall || bottom <= top) return '';
|
||||
return [
|
||||
`M ${bevelSizeSmall} ${top}`,
|
||||
`L ${right} ${top}`,
|
||||
`L ${right} ${bottom - br}`,
|
||||
`L ${right - br} ${bottom}`,
|
||||
`L ${bevelSizeSmall} ${bottom}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full progress bar fill area path (inset 3px from frame on all sides).
|
||||
* This is the region where the surface/accent gradient fills.
|
||||
* Bottom-left corner: the outer bevel diagonal, the border band, and the beveled bottom of
|
||||
* the progress strip. The strip's bottom triangle is olive (accent under 60% background)
|
||||
* when empty and solid accent once any progress fills it, matching the strip above.
|
||||
*/
|
||||
function buildProgressAreaPath(
|
||||
cardH: number,
|
||||
bw: number,
|
||||
bevelSizeSmall: number,
|
||||
): string {
|
||||
const left = bw;
|
||||
const right = bevelSizeSmall - bw;
|
||||
const top = bw;
|
||||
const bevelStartY = cardH - bevelSizeSmall;
|
||||
const bottomRight = cardH - bw * 2; // y at bottom-right of fill area (on bevel diagonal)
|
||||
|
||||
if (right <= left || bevelStartY <= top) return '';
|
||||
|
||||
return [
|
||||
`M ${left} ${top}`,
|
||||
`L ${right} ${top}`,
|
||||
`L ${right} ${bottomRight}`,
|
||||
`L ${left} ${bevelStartY}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
function CornerBottomLeft({size, bw, accent, bg, filled}: {size: number; bw: number; accent: string; bg: string; filled: boolean}) {
|
||||
const s = size;
|
||||
// strip triangle: (bw,0) → (s-bw,0) → (s-bw, s-2bw)
|
||||
const tri = `M ${bw} 0 L ${s - bw} 0 L ${s - bw} ${s - 2 * bw} Z`;
|
||||
return (
|
||||
<Svg
|
||||
pointerEvents="none"
|
||||
style={[styles.abs, {left: 0, bottom: 0, zIndex: 2}]}
|
||||
width={s}
|
||||
height={s}
|
||||
viewBox={`0 0 ${s} ${s}`}>
|
||||
{/* everything above the outer diagonal is card (accent border band by default) */}
|
||||
<Path d={`M 0 0 L ${s} 0 L ${s} ${s} Z`} fill={accent} />
|
||||
<Path d={tri} fill={accent} />
|
||||
{!filled && <Path d={tri} fill={bg} opacity={0.6} />}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the accent-colored portion of the progress bar (fills from bottom).
|
||||
* Returns empty string when progress is 0.
|
||||
*/
|
||||
function buildProgressFillPath(
|
||||
cardH: number,
|
||||
bw: number,
|
||||
bevelSizeSmall: number,
|
||||
progress: number,
|
||||
): string {
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
if (p === 0) return '';
|
||||
|
||||
const left = bw;
|
||||
const right = bevelSizeSmall - bw;
|
||||
const top = bw;
|
||||
const bevelStartY = cardH - bevelSizeSmall;
|
||||
const bottomRight = cardH - bw * 2;
|
||||
|
||||
if (right <= left || bevelStartY <= top) return '';
|
||||
|
||||
// Total fill area height (along the left edge, from top to bevelStartY)
|
||||
const areaH = bevelStartY - top;
|
||||
// How much of the area is filled from the bottom
|
||||
const fillTop = top + areaH * (1 - p);
|
||||
|
||||
if (fillTop >= bevelStartY) {
|
||||
// Fill is entirely within the bevel zone
|
||||
// The bevel diagonal goes from (left, bevelStartY) to (right, bottomRight)
|
||||
// At y=fillTop, x on the diagonal: x = left + (fillTop - bevelStartY) * (right - left) / (bottomRight - bevelStartY)
|
||||
const bevelH = bottomRight - bevelStartY;
|
||||
if (bevelH <= 0) return '';
|
||||
const xAtFillTop = left + ((fillTop - bevelStartY) / bevelH) * (right - left);
|
||||
if (xAtFillTop >= right) return '';
|
||||
return [
|
||||
`M ${xAtFillTop} ${fillTop}`,
|
||||
`L ${right} ${fillTop}`,
|
||||
`L ${right} ${bottomRight}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
// Fill extends above the bevel zone — rectangle + bevel triangle
|
||||
return [
|
||||
`M ${left} ${fillTop}`,
|
||||
`L ${right} ${fillTop}`,
|
||||
`L ${right} ${bottomRight}`,
|
||||
`L ${left} ${bevelStartY}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Card component with SVG beveled corners
|
||||
* DT-styled Card component with beveled corners
|
||||
*
|
||||
* @example
|
||||
* <DTCard title="Detected Chip" mode="normal">
|
||||
@@ -240,30 +192,15 @@ export function DTCard({
|
||||
const accentColor = getVariantColor(theme, mode, borderColor);
|
||||
const shouldShowHeader = showHeader ?? !!title;
|
||||
const bgColor = backgroundColor ?? theme.colors.background;
|
||||
|
||||
const {width, height} = dimensions;
|
||||
const useBevels = theme.custom.bevelMd > 0;
|
||||
const bw = borderWidth;
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
|
||||
// Outer bevel path (full card shape)
|
||||
const outerPath = useBevels && hasDimensions
|
||||
? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0)
|
||||
: '';
|
||||
// Inner bevel path — left edge at bevelSizeSmall (progress bar zone)
|
||||
const innerPath = useBevels && hasDimensions
|
||||
? buildInnerCardPath(width, height, bevelSize, borderWidth, bevelSizeSmall)
|
||||
: '';
|
||||
|
||||
// Progress bar paths — computed once, reused in progress SVG and frame overlay
|
||||
const progressAreaPath = useBevels && hasDimensions
|
||||
? buildProgressAreaPath(height, borderWidth, bevelSizeSmall)
|
||||
: '';
|
||||
const progressFillPath = useBevels && hasDimensions && progress > 0
|
||||
? buildProgressFillPath(height, borderWidth, bevelSizeSmall, progress)
|
||||
: '';
|
||||
// Frame overlay: outer + inner + progressArea hole (evenodd punches window for progress bar)
|
||||
const framePath = useBevels && hasDimensions
|
||||
? outerPath + ' ' + innerPath + (progressAreaPath ? ' ' + progressAreaPath : '')
|
||||
: '';
|
||||
// Bevels shrink on tiny cards, as the old path builder did (min of a third of each side).
|
||||
const {width, height} = dimensions;
|
||||
const clamp = (v: number) => (hasDimensions ? Math.min(v, width / 3, height / 3) : v);
|
||||
const bev = clamp(bevelSize);
|
||||
const bss = clamp(bevelSizeSmall);
|
||||
|
||||
const content = (
|
||||
<View
|
||||
@@ -279,63 +216,21 @@ export function DTCard({
|
||||
style,
|
||||
]}
|
||||
onLayout={onLayout}>
|
||||
{/* Background fill (behind content) — beveled mode only */}
|
||||
{useBevels && hasDimensions && (
|
||||
<Svg
|
||||
style={StyleSheet.absoluteFill}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}>
|
||||
<Path d={innerPath} fill={bgColor} />
|
||||
</Svg>
|
||||
)}
|
||||
{/* Progress bar fill area — beveled mode */}
|
||||
{useBevels && hasDimensions && (
|
||||
<Svg
|
||||
style={[StyleSheet.absoluteFill, {zIndex: 1}]}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
pointerEvents="none">
|
||||
{/* Accent base — visible through the semi-transparent surface above.
|
||||
Matches web where ::after sits over the accent-colored card bg. */}
|
||||
<Path d={progressAreaPath} fill={accentColor} />
|
||||
{/* Surface fill over accent base — 0.6 opacity lets accent bleed through.
|
||||
Matches web --dt-progress-empty-opacity default. */}
|
||||
<Path
|
||||
d={progressAreaPath}
|
||||
fill={bgColor}
|
||||
opacity={0.6}
|
||||
/>
|
||||
{/* Accent fill from bottom (progressed portion) */}
|
||||
{progressFillPath !== '' && (
|
||||
<Path
|
||||
d={progressFillPath}
|
||||
fill={accentColor}
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
)}
|
||||
{/* Progress bar — non-beveled (classic) mode */}
|
||||
{!useBevels && (
|
||||
<View style={[styles.progressBar, {
|
||||
width: bevelSizeSmall,
|
||||
left: borderWidth,
|
||||
top: borderWidth,
|
||||
bottom: borderWidth,
|
||||
borderBottomLeftRadius: theme.custom.radius > 0 ? Math.max(0, theme.custom.radius - borderWidth) : 0,
|
||||
}]}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
backgroundColor: accentColor,
|
||||
height: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{useBevels && (
|
||||
<>
|
||||
{/* Body background: the inner rectangle minus the bottom-right corner square. */}
|
||||
<View pointerEvents="none" style={[styles.abs, {left: bss - 1, top: bw - 1, right: bw - 1, bottom: bev - 1, backgroundColor: bgColor}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, {left: bss - 1, right: bev - 1, bottom: bw - 1, height: bev, backgroundColor: bgColor}]} />
|
||||
<CornerBottomRightBg size={bev} bw={bw} bg={bgColor} />
|
||||
|
||||
{/* Progress strip: accent under 60% background, filled from the bottom by `progress`. */}
|
||||
<View pointerEvents="none" style={[styles.abs, {left: bw - 1, width: bss - 2 * bw + 2, top: bw - 1, bottom: bss - 1, backgroundColor: accentColor, zIndex: 1, overflow: 'hidden'}]}>
|
||||
<View style={[StyleSheet.absoluteFill, {backgroundColor: bgColor, opacity: 0.6}]} />
|
||||
{p > 0 && <View style={[styles.abs, {left: 0, right: 0, bottom: 0, height: `${Math.round(p * 100)}%`, backgroundColor: accentColor}]} />}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[styles.innerContainer, useBevels && {paddingLeft: bevelSizeSmall - borderWidth}]}>
|
||||
{shouldShowHeader && (
|
||||
<View style={[styles.header, {backgroundColor: accentColor}, growArea === 'title' && {flexGrow: 1}, !!(title && headerRight) && styles.headerRow]}>
|
||||
@@ -351,23 +246,18 @@ export function DTCard({
|
||||
)}
|
||||
<View style={[styles.content, {padding}, growArea === 'body' && {flexGrow: 1}, contentStyle]}>{children}</View>
|
||||
</View>
|
||||
{/* Frame overlay (above content) — beveled mode only.
|
||||
Uses evenodd with 3 sub-paths: outer + inner + progressArea.
|
||||
The progress area path punches a hole in the frame so the
|
||||
progress bar SVG underneath (zIndex:1) shows through. */}
|
||||
{useBevels && hasDimensions && (
|
||||
<Svg
|
||||
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
pointerEvents="none">
|
||||
<Path
|
||||
d={framePath}
|
||||
fillRule="evenodd"
|
||||
fill={accentColor}
|
||||
/>
|
||||
</Svg>
|
||||
|
||||
{useBevels && (
|
||||
<>
|
||||
{/* Border edges as Views: top, left outer, left inner (strip/body divider), right, bottom. */}
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {left: 0, right: 0, top: 0, height: bw, backgroundColor: accentColor}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {left: 0, width: bw, top: 0, bottom: bss - 1, backgroundColor: accentColor}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {left: bss - bw, width: bw, top: 0, bottom: bss - 1, backgroundColor: accentColor}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {right: 0, width: bw, top: 0, bottom: bev - 1, backgroundColor: accentColor}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {left: bss - 1, right: bev - 1, bottom: 0, height: bw, backgroundColor: accentColor}]} />
|
||||
<CornerBottomRightBorder size={bev} bw={bw} accent={accentColor} />
|
||||
<CornerBottomLeft size={bss} bw={bw} accent={accentColor} bg={bgColor} filled={p > 0} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -408,6 +298,12 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
abs: {
|
||||
position: 'absolute',
|
||||
},
|
||||
edge: {
|
||||
zIndex: 2,
|
||||
},
|
||||
innerContainer: {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
@@ -434,20 +330,4 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
content: {},
|
||||
frameOverlay: {
|
||||
zIndex: 2,
|
||||
},
|
||||
progressBar: {
|
||||
position: 'absolute' as const,
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 3,
|
||||
justifyContent: 'flex-end' as const,
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
progressFill: {
|
||||
width: '100%' as const,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* DTMotionImage — an animated image (GIF, animated WebP) that shows a still
|
||||
* frame when the viewer has asked for stills, turned animation off, or is on
|
||||
* a brand that never animates. GIF timing cannot be scaled, so the only
|
||||
* honest treatment of an animated picture for motion-sensitive viewers is
|
||||
* not to play it.
|
||||
*/
|
||||
|
||||
import {Image, type ImageProps, type ImageSourcePropType} from 'react-native';
|
||||
import {useDTStillImages} from '../theme/DTThemeProvider';
|
||||
|
||||
interface DTMotionImageProps extends Omit<ImageProps, 'source'> {
|
||||
/** The animated source */
|
||||
source: ImageSourcePropType;
|
||||
/** A single-frame version of the same picture */
|
||||
still: ImageSourcePropType;
|
||||
}
|
||||
|
||||
export function DTMotionImage({source, still, ...rest}: DTMotionImageProps) {
|
||||
const useStill = useDTStillImages();
|
||||
return <Image source={useStill ? still : source} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* DT Multi Select Component
|
||||
*
|
||||
* Pick several options from a docked panel: an always-visible summary header ("TRACKS 2/3 · VOCALS, BASS") that
|
||||
* expands its checkbox list in place when tapped and collapses it again. Part of the layout, not an overlay; the
|
||||
* app places it (e.g. below its ScrollView, above the bottom safe-area inset).
|
||||
*
|
||||
* Selection rules live in ../utils/selection.ts (unit tested).
|
||||
*/
|
||||
|
||||
import {ReactNode, useState} from 'react';
|
||||
import {LayoutAnimation, Pressable, ScrollView, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import {useDTMotionScale, useDTTheme} from '../theme/DTThemeProvider';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {isLocked, selectAll, summaryText, toggleValue} from '../utils/selection';
|
||||
import {DTButton} from './DTButton';
|
||||
import {DTCheckbox} from './DTCheckbox';
|
||||
import {DTSelectPanel} from './DTSelectPanel';
|
||||
|
||||
export const DT_SELECT_ROW_HEIGHT = 48;
|
||||
|
||||
export interface DTSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Right-aligned detail, e.g. "565 notes". */
|
||||
meta?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DTMultiSelectProps {
|
||||
options: DTSelectOption[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
/** Header label, e.g. "Tracks". */
|
||||
label: string;
|
||||
/** Summary when nothing is selected. */
|
||||
placeholder?: string;
|
||||
/** Singular noun for the summary: "track" gives "3 tracks" and "All tracks". */
|
||||
itemNoun?: string;
|
||||
minSelected?: number;
|
||||
maxSelected?: number;
|
||||
/** ALL in the header while expanded. @default true */
|
||||
showSelectAll?: boolean;
|
||||
/** App actions in the header before ALL, e.g. compact DTButtons such as "+ ADD". */
|
||||
headerActions?: ReactNode;
|
||||
/** Rows shown before the list scrolls; the default cuts a row in half to signal more. @default 5.5 */
|
||||
visibleRows?: number;
|
||||
/** Controlled expansion; uncontrolled (starting collapsed) when omitted. */
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
/** Appended to the summary, e.g. "977 notes". */
|
||||
summaryDetail?: string;
|
||||
helperText?: string;
|
||||
/** Error message; also switches the accent to the warning colour. */
|
||||
error?: string;
|
||||
/** @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
disabled?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <DTMultiSelect
|
||||
* label="Tracks"
|
||||
* itemNoun="track"
|
||||
* options={[{value: 'vocals', label: 'Vocals', meta: '565 notes'}, {value: 'bass', label: 'Bass', meta: '412 notes'}]}
|
||||
* value={included}
|
||||
* onChange={setIncluded}
|
||||
* minSelected={1}
|
||||
* />
|
||||
*/
|
||||
export function DTMultiSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = '',
|
||||
itemNoun,
|
||||
minSelected,
|
||||
maxSelected,
|
||||
showSelectAll = true,
|
||||
headerActions,
|
||||
visibleRows = 5.5,
|
||||
expanded: expandedProp,
|
||||
onExpandedChange,
|
||||
summaryDetail,
|
||||
helperText,
|
||||
error,
|
||||
variant = 'normal',
|
||||
disabled = false,
|
||||
style,
|
||||
}: DTMultiSelectProps) {
|
||||
const theme = useDTTheme();
|
||||
const motionScale = useDTMotionScale();
|
||||
const [expandedState, setExpandedState] = useState(false);
|
||||
const expanded = expandedProp ?? expandedState;
|
||||
const useBevels = theme.custom.bevelMd > 0;
|
||||
const accent = getVariantColor(theme, error ? 'warning' : variant);
|
||||
const limits = {min: minSelected, max: maxSelected};
|
||||
|
||||
const summary = summaryText(options, value, {placeholder, itemNoun});
|
||||
const summaryLine = summary.placeholder || !summaryDetail ? summary : {...summary, text: `${summary.text} · ${summaryDetail}`};
|
||||
|
||||
const setExpanded = (next: boolean) => {
|
||||
// ponytail: LayoutAnimation rather than a measured Animated height; skipped entirely at motion scale 0.
|
||||
if (motionScale > 0) LayoutAnimation.configureNext(LayoutAnimation.create(200 * motionScale, 'easeInEaseOut', 'opacity'));
|
||||
if (expandedProp === undefined) setExpandedState(next);
|
||||
onExpandedChange?.(next);
|
||||
};
|
||||
|
||||
const atMinimum = minSelected !== undefined && minSelected > 0 && value.length <= minSelected;
|
||||
const noun = itemNoun ?? 'option';
|
||||
const hint =
|
||||
error ??
|
||||
(expanded && atMinimum
|
||||
? `At least ${minSelected} ${minSelected === 1 ? `${noun} stays` : `${noun}s stay`} on.`
|
||||
: helperText);
|
||||
|
||||
const allSelected = options.every(option => option.disabled || value.includes(option.value));
|
||||
const actions =
|
||||
headerActions || (expanded && showSelectAll) ? (
|
||||
<>
|
||||
{headerActions}
|
||||
{expanded && showSelectAll ? (
|
||||
<DTButton
|
||||
variant={error ? 'warning' : variant}
|
||||
disabled={disabled || allSelected}
|
||||
onPress={() => onChange(selectAll(options, value, {max: maxSelected}))}>
|
||||
ALL
|
||||
</DTButton>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
const tint = `${accent}${useBevels ? '1A' : '14'}`;
|
||||
const divider = useBevels ? `${accent}2E` : theme.custom.border;
|
||||
|
||||
return (
|
||||
<DTSelectPanel
|
||||
label={label}
|
||||
count={`${value.length}/${options.length}`}
|
||||
summary={summaryLine}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
accent={accent}
|
||||
error={!!error}
|
||||
disabled={disabled}
|
||||
headerActions={actions}
|
||||
hint={hint}
|
||||
style={style}>
|
||||
{expanded ? (
|
||||
<ScrollView nestedScrollEnabled style={{maxHeight: DT_SELECT_ROW_HEIGHT * visibleRows}}>
|
||||
{options.map((option, index) => {
|
||||
const checked = value.includes(option.value);
|
||||
const locked = disabled || isLocked(option, value, limits);
|
||||
const toggle = () => onChange(toggleValue(options, value, option.value, limits));
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={toggle}
|
||||
disabled={locked}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{checked, disabled: locked}}
|
||||
accessibilityLabel={option.meta ? `${option.label}, ${option.meta}` : option.label}
|
||||
style={({pressed}) => [
|
||||
styles.row,
|
||||
index < options.length - 1 && [styles.rowDivider, {borderBottomColor: divider}],
|
||||
checked && {backgroundColor: tint},
|
||||
pressed && !useBevels && {backgroundColor: theme.colors.surfaceVariant},
|
||||
]}>
|
||||
<View importantForAccessibility="no-hide-descendants">
|
||||
<DTCheckbox checked={checked} onPress={toggle} disabled={locked} size={20} color={accent} style={styles.checkbox} />
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.label,
|
||||
{color: theme.colors.onSurface},
|
||||
!useBevels && styles.labelClassic,
|
||||
locked && !disabled && styles.locked,
|
||||
]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.meta ? <Text style={[styles.meta, {color: theme.colors.onSurfaceVariant}]}>{option.meta}</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</DTSelectPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
minHeight: DT_SELECT_ROW_HEIGHT,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
rowDivider: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
checkbox: {
|
||||
minHeight: 0,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
label: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
labelClassic: {
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
locked: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* DT Select Component
|
||||
*
|
||||
* Pick one option from a docked panel: the same frame and header as DTMultiSelect (an always-visible summary that
|
||||
* expands in place), with rows that have no checkbox. The chosen row is filled at 70 % accent like DTMenu's active
|
||||
* item, and picking collapses the panel.
|
||||
*/
|
||||
|
||||
import {ReactNode, useState} from 'react';
|
||||
import {LayoutAnimation, Pressable, ScrollView, StyleProp, StyleSheet, ViewStyle} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import {useDTMotionScale, useDTTheme} from '../theme/DTThemeProvider';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {DT_SELECT_ROW_HEIGHT, type DTSelectOption} from './DTMultiSelect';
|
||||
import {DTSelectPanel} from './DTSelectPanel';
|
||||
|
||||
export interface DTSelectProps {
|
||||
options: DTSelectOption[];
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
/** Header label, e.g. "Track". */
|
||||
label: string;
|
||||
/** Summary when nothing is chosen. */
|
||||
placeholder?: string;
|
||||
/** App actions in the header, e.g. compact DTButtons. */
|
||||
headerActions?: ReactNode;
|
||||
/** Rows shown before the list scrolls. @default 5.5 */
|
||||
visibleRows?: number;
|
||||
/** Controlled expansion; uncontrolled (starting collapsed) when omitted. */
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
/** Appended to the summary, e.g. "565 notes". */
|
||||
summaryDetail?: string;
|
||||
helperText?: string;
|
||||
/** Error message; also switches the accent to the warning colour. */
|
||||
error?: string;
|
||||
/** @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
disabled?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <DTSelect label="Track" options={tracks} value={trackId} onChange={setTrackId} placeholder="Choose a track" />
|
||||
*/
|
||||
export function DTSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = '',
|
||||
headerActions,
|
||||
visibleRows = 5.5,
|
||||
expanded: expandedProp,
|
||||
onExpandedChange,
|
||||
summaryDetail,
|
||||
helperText,
|
||||
error,
|
||||
variant = 'normal',
|
||||
disabled = false,
|
||||
style,
|
||||
}: DTSelectProps) {
|
||||
const theme = useDTTheme();
|
||||
const motionScale = useDTMotionScale();
|
||||
const [expandedState, setExpandedState] = useState(false);
|
||||
const expanded = expandedProp ?? expandedState;
|
||||
const useBevels = theme.custom.bevelMd > 0;
|
||||
const accent = getVariantColor(theme, error ? 'warning' : variant);
|
||||
|
||||
const chosen = options.find(option => option.value === value);
|
||||
const summary = chosen
|
||||
? {text: summaryDetail ? `${chosen.label} · ${summaryDetail}` : chosen.label, placeholder: false}
|
||||
: {text: placeholder, placeholder: true};
|
||||
|
||||
const setExpanded = (next: boolean) => {
|
||||
if (motionScale > 0) LayoutAnimation.configureNext(LayoutAnimation.create(200 * motionScale, 'easeInEaseOut', 'opacity'));
|
||||
if (expandedProp === undefined) setExpandedState(next);
|
||||
onExpandedChange?.(next);
|
||||
};
|
||||
|
||||
const divider = useBevels ? `${accent}2E` : theme.custom.border;
|
||||
|
||||
return (
|
||||
<DTSelectPanel
|
||||
label={label}
|
||||
summary={summary}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
accent={accent}
|
||||
error={!!error}
|
||||
disabled={disabled}
|
||||
headerActions={headerActions}
|
||||
hint={error ?? helperText}
|
||||
style={style}>
|
||||
{expanded ? (
|
||||
<ScrollView nestedScrollEnabled style={{maxHeight: DT_SELECT_ROW_HEIGHT * visibleRows}}>
|
||||
{options.map((option, index) => {
|
||||
const isChosen = option.value === value;
|
||||
const locked = disabled || !!option.disabled;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => {
|
||||
onChange(option.value);
|
||||
setExpanded(false);
|
||||
}}
|
||||
disabled={locked}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{checked: isChosen, disabled: locked}}
|
||||
accessibilityLabel={option.meta ? `${option.label}, ${option.meta}` : option.label}
|
||||
style={({pressed}) => [
|
||||
styles.row,
|
||||
index < options.length - 1 && [styles.rowDivider, {borderBottomColor: divider}],
|
||||
isChosen && {backgroundColor: `${accent}B3`},
|
||||
pressed && !isChosen && {backgroundColor: `${accent}1A`},
|
||||
]}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.label,
|
||||
{color: isChosen ? theme.colors.onPrimary : theme.colors.onSurface},
|
||||
!useBevels && styles.labelClassic,
|
||||
locked && !disabled && styles.locked,
|
||||
]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.meta ? (
|
||||
<Text style={[styles.meta, {color: isChosen ? theme.colors.onPrimary : theme.colors.onSurfaceVariant}]}>{option.meta}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</DTSelectPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
minHeight: DT_SELECT_ROW_HEIGHT,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
rowDivider: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
label: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
labelClassic: {
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
locked: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* DT Select Panel (internal)
|
||||
*
|
||||
* The frame and header shared by DTMultiSelect and DTSelect: a panel that is part of the layout (apps dock it,
|
||||
* e.g. below a ScrollView), with an always-visible summary header that expands its list in place.
|
||||
*
|
||||
* Frame, DT brand: built like DTCard, not as one full-height SVG. react-native-svg draws a tall `Svg`
|
||||
* progressively lower the taller it is, so the straight edges and the top band are absolutely positioned Views
|
||||
* and SVG draws only the two small bevelled bottom corners: a background layer under the content and the border
|
||||
* band over it. Classic brand (no bevels): a bordered View with `radiusLg` corners.
|
||||
*/
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||
|
||||
const BORDER = 2;
|
||||
const TOP_BAND = 5;
|
||||
const BEVEL = 18;
|
||||
|
||||
interface DTSelectPanelProps {
|
||||
label: string;
|
||||
/** e.g. "2/3"; omitted for a single choice. */
|
||||
count?: string;
|
||||
summary: {text: string; placeholder: boolean};
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
accent: string;
|
||||
error: boolean;
|
||||
disabled: boolean;
|
||||
headerActions?: ReactNode;
|
||||
/** Rendered below the header (the list, only while expanded). */
|
||||
children?: ReactNode;
|
||||
hint?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
function CornerBackground({side, bg}: {side: 'left' | 'right'; bg: string}) {
|
||||
const s = BEVEL;
|
||||
const i = s - BORDER;
|
||||
const d = side === 'right' ? `M 0 0 L ${i} 0 L 0 ${i} Z` : `M ${s} 0 L ${BORDER} 0 L ${s} ${i} Z`;
|
||||
return (
|
||||
<Svg pointerEvents="none" style={[styles.abs, {bottom: 0, [side]: 0}]} width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
||||
<Path d={d} fill={bg} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CornerBorder({side, accent}: {side: 'left' | 'right'; accent: string}) {
|
||||
const s = BEVEL;
|
||||
const i = s - BORDER;
|
||||
const d = side === 'right' ? `M ${i} 0 L ${s} 0 L 0 ${s} L 0 ${i} Z` : `M ${BORDER} 0 L 0 0 L ${s} ${s} L ${s} ${i} Z`;
|
||||
return (
|
||||
<Svg pointerEvents="none" style={[styles.abs, styles.edge, {bottom: 0, [side]: 0}]} width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
||||
<Path d={d} fill={accent} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron({expanded, color}: {expanded: boolean; color: string}) {
|
||||
// Points up while collapsed: the list opens upward from a docked panel.
|
||||
return (
|
||||
<Svg width={12} height={8} viewBox="0 0 12 8" style={{transform: [{rotate: expanded ? '0deg' : '180deg'}]}}>
|
||||
<Path d="M0 0h12L6 8z" fill={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DTSelectPanel({
|
||||
label,
|
||||
count,
|
||||
summary,
|
||||
expanded,
|
||||
onToggle,
|
||||
accent,
|
||||
error,
|
||||
disabled,
|
||||
headerActions,
|
||||
children,
|
||||
hint,
|
||||
style,
|
||||
}: DTSelectPanelProps) {
|
||||
const theme = useDTTheme();
|
||||
const useBevels = theme.custom.bevelMd > 0;
|
||||
const bg = theme.colors.surface;
|
||||
const secondary = theme.colors.onSurfaceVariant;
|
||||
const divider = useBevels ? `${accent}4D` : theme.custom.border;
|
||||
|
||||
const header = (
|
||||
<View style={[styles.header, expanded && [styles.headerExpanded, {borderBottomColor: divider}]]}>
|
||||
<Pressable
|
||||
onPress={onToggle}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{expanded, disabled}}
|
||||
accessibilityLabel={[label, count, summary.text].filter(Boolean).join(', ')}
|
||||
style={styles.toggle}>
|
||||
<View style={styles.headText}>
|
||||
<View style={styles.headLine}>
|
||||
<Text style={[styles.title, {color: accent}, !useBevels && styles.titleClassic]}>{label}</Text>
|
||||
{count ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.count,
|
||||
{color: accent},
|
||||
!useBevels && [styles.countClassic, {backgroundColor: `${accent}24`}],
|
||||
]}>
|
||||
{count}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
accessibilityLiveRegion="polite"
|
||||
style={[styles.summary, {color: secondary}, summary.placeholder && styles.placeholder, !useBevels && styles.summaryClassic]}>
|
||||
{summary.text}
|
||||
</Text>
|
||||
</View>
|
||||
<Chevron expanded={expanded} color={useBevels || expanded ? accent : secondary} />
|
||||
</Pressable>
|
||||
{headerActions ? <View style={styles.actions}>{headerActions}</View> : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{header}
|
||||
{children}
|
||||
{hint ? <Text style={[styles.hint, {color: error ? accent : secondary}]}>{hint}</Text> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!useBevels) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.classic,
|
||||
{
|
||||
borderColor: error ? accent : theme.custom.border,
|
||||
borderRadius: theme.custom.radiusLg,
|
||||
backgroundColor: bg,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{body}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, {opacity: disabled ? 0.5 : 1}, style]}>
|
||||
{/* Background: everything inside the border except the two bevelled corner squares, then those corners' insides. */}
|
||||
<View pointerEvents="none" style={[styles.abs, {left: BORDER - 1, right: BORDER - 1, top: TOP_BAND - 1, bottom: BEVEL - 1, backgroundColor: bg}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, {left: BEVEL - 1, right: BEVEL - 1, bottom: BORDER - 1, height: BEVEL, backgroundColor: bg}]} />
|
||||
<CornerBackground side="left" bg={bg} />
|
||||
<CornerBackground side="right" bg={bg} />
|
||||
|
||||
<View style={[styles.content, {paddingTop: TOP_BAND, paddingHorizontal: BORDER, paddingBottom: BEVEL - 4}]}>{body}</View>
|
||||
|
||||
{/* Border edges as Views over the content: top band, left, right, bottom between the corners. */}
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {left: 0, right: 0, top: 0, height: TOP_BAND, backgroundColor: accent}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {left: 0, width: BORDER, top: 0, bottom: BEVEL - 1, backgroundColor: accent}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {right: 0, width: BORDER, top: 0, bottom: BEVEL - 1, backgroundColor: accent}]} />
|
||||
<View pointerEvents="none" style={[styles.abs, styles.edge, {left: BEVEL - 1, right: BEVEL - 1, bottom: 0, height: BORDER, backgroundColor: accent}]} />
|
||||
<CornerBorder side="left" accent={accent} />
|
||||
<CornerBorder side="right" accent={accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
abs: {
|
||||
position: 'absolute',
|
||||
},
|
||||
edge: {
|
||||
zIndex: 2,
|
||||
},
|
||||
content: {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
},
|
||||
classic: {
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
headerExpanded: {
|
||||
borderBottomWidth: 1,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
toggle: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
minHeight: 44,
|
||||
},
|
||||
headText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 3,
|
||||
},
|
||||
headLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.6,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
titleClassic: {
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
count: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
countClassic: {
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
summary: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
summaryClassic: {
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
placeholder: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
@@ -19,12 +19,16 @@ import {
|
||||
} from '@dangerousthings/tokens';
|
||||
import {DTCard} from './DTCard';
|
||||
import {DTRadioGroup, DTRadioOption} from './DTRadioGroup';
|
||||
import {DTSwitch} from './DTSwitch';
|
||||
|
||||
export interface DTSettingsPanelProps {
|
||||
brand?: ThemeBrand;
|
||||
onBrandChange?: (brand: ThemeBrand) => void;
|
||||
motionScale?: number;
|
||||
onMotionScaleChange?: (scale: number) => void;
|
||||
/** Show animated images as a single frame. Forced on (and disabled) when motion is off or the brand never animates. */
|
||||
stillImages?: boolean;
|
||||
onStillImagesChange?: (still: boolean) => void;
|
||||
colorVision?: ColorVisionMode;
|
||||
onColorVisionChange?: (mode: ColorVisionMode) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
@@ -47,15 +51,18 @@ export function DTSettingsPanel({
|
||||
onBrandChange,
|
||||
motionScale = 1,
|
||||
onMotionScaleChange,
|
||||
stillImages = false,
|
||||
onStillImagesChange,
|
||||
colorVision = 'none',
|
||||
onColorVisionChange,
|
||||
style,
|
||||
}: DTSettingsPanelProps) {
|
||||
const animates = brands[brand]?.animations ?? true;
|
||||
const stillForced = !animates || motionScale === 0;
|
||||
|
||||
return (
|
||||
<View style={[styles.root, style]}>
|
||||
{(onBrandChange || onMotionScaleChange) && (
|
||||
{(onBrandChange || onMotionScaleChange || onStillImagesChange) && (
|
||||
<DTCard title="Theme" mode="normal">
|
||||
<View style={styles.body}>
|
||||
{onBrandChange && (
|
||||
@@ -91,6 +98,21 @@ export function DTSettingsPanel({
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{onStillImagesChange && (
|
||||
<View style={styles.stack}>
|
||||
<DTSwitch
|
||||
value={stillForced || stillImages}
|
||||
disabled={stillForced}
|
||||
onValueChange={onStillImagesChange}
|
||||
label="Show animated images as stills"
|
||||
/>
|
||||
<Text variant="bodySmall" style={styles.muted}>
|
||||
For motion sensitivity. Animated pictures show a single frame instead of playing.
|
||||
{stillForced ? ' Always on while animation is off.' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</DTCard>
|
||||
)}
|
||||
@@ -123,4 +145,5 @@ const styles = StyleSheet.create({
|
||||
body: {gap: 24, padding: 16},
|
||||
legend: {textTransform: 'uppercase', letterSpacing: 1.5, opacity: 0.7, marginBottom: 12},
|
||||
muted: {opacity: 0.6},
|
||||
stack: {gap: 8},
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export {
|
||||
} from './theme/paperTheme';
|
||||
export type { DTExtendedTheme } from './theme/paperTheme';
|
||||
|
||||
export { DTThemeProvider, useDTTheme, useDTMotionScale } from './theme/DTThemeProvider';
|
||||
export { DTThemeProvider, useDTTheme, useDTMotionScale, useDTStillImages } from './theme/DTThemeProvider';
|
||||
export { buildThemeFromBrand } from './theme/buildTheme';
|
||||
|
||||
// Shared utilities
|
||||
@@ -45,6 +45,10 @@ export { DTCheckbox } from './components/DTCheckbox';
|
||||
export { DTSwitch } from './components/DTSwitch';
|
||||
export { DTRadioGroup, DTRadioOption } from './components/DTRadioGroup';
|
||||
export { DTQuantityStepper } from './components/DTQuantityStepper';
|
||||
export { DTMultiSelect } from './components/DTMultiSelect';
|
||||
export type { DTMultiSelectProps, DTSelectOption } from './components/DTMultiSelect';
|
||||
export { DTSelect } from './components/DTSelect';
|
||||
export type { DTSelectProps } from './components/DTSelect';
|
||||
|
||||
// Component exports — layout & containers
|
||||
export { DTProgressBar } from './components/DTProgressBar';
|
||||
@@ -59,6 +63,7 @@ export { DTStaggerContainer } from './components/DTStaggerContainer';
|
||||
// Component exports — settings
|
||||
export { DTSettingsPanel, motionLabel } from './components/DTSettingsPanel';
|
||||
export type { DTSettingsPanelProps } from './components/DTSettingsPanel';
|
||||
export { DTMotionImage } from './components/DTMotionImage';
|
||||
|
||||
// Component exports — complex interactive
|
||||
export { DTModal } from './components/DTModal';
|
||||
|
||||
@@ -16,8 +16,8 @@ import { buildThemeFromBrand } from './buildTheme';
|
||||
|
||||
const DTThemeContext = createContext<DTExtendedTheme>(DTExtendedDarkTheme);
|
||||
|
||||
/** Animation speed 0..1; 0 on brands that never animate. Read by useScaleIn/usePulse. */
|
||||
const DTMotionContext = createContext(1);
|
||||
/** Animation speed 0..1 (0 on brands that never animate) and whether animated images show as stills. */
|
||||
const DTMotionContext = createContext({ scale: 1, still: false });
|
||||
|
||||
/**
|
||||
* Hook to access the full DT theme including custom properties
|
||||
@@ -30,8 +30,14 @@ export function useDTTheme(): DTExtendedTheme {
|
||||
return useContext(DTThemeContext);
|
||||
}
|
||||
|
||||
/** Animation speed 0..1. Read by useScaleIn/usePulse. */
|
||||
export function useDTMotionScale(): number {
|
||||
return useContext(DTMotionContext);
|
||||
return useContext(DTMotionContext).scale;
|
||||
}
|
||||
|
||||
/** True when animated images should render a single frame. See DTMotionImage. */
|
||||
export function useDTStillImages(): boolean {
|
||||
return useContext(DTMotionContext).still;
|
||||
}
|
||||
|
||||
interface DTThemeProviderProps {
|
||||
@@ -44,6 +50,8 @@ interface DTThemeProviderProps {
|
||||
colorVision?: ColorVisionMode;
|
||||
/** Animation speed 0..1 @default 1. Forced to 0 for brands whose tokens disable animations (classic). */
|
||||
motionScale?: number;
|
||||
/** Show animated images (GIFs) as a single frame @default false. Also true at motion 0. */
|
||||
stillImages?: boolean;
|
||||
/**
|
||||
* Include SafeAreaProvider wrapper (default: true)
|
||||
* Set to false if you already have SafeAreaProvider higher in tree
|
||||
@@ -67,13 +75,15 @@ export function DTThemeProvider({
|
||||
brand = 'dt',
|
||||
colorVision = 'none',
|
||||
motionScale = 1,
|
||||
stillImages = false,
|
||||
includeSafeArea = true,
|
||||
}: DTThemeProviderProps) {
|
||||
const resolved = useMemo(
|
||||
() => theme ?? buildThemeFromBrand(brands[brand], { colorVision }),
|
||||
[theme, brand, colorVision],
|
||||
);
|
||||
const motion = theme || brands[brand].animations ? motionScale : 0;
|
||||
const scale = theme || brands[brand].animations ? motionScale : 0;
|
||||
const motion = useMemo(() => ({ scale, still: stillImages || scale === 0 }), [scale, stillImages]);
|
||||
|
||||
const content = (
|
||||
<DTThemeContext.Provider value={resolved}>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {isLocked, selectAll, summaryText, toggleValue, type SelectionOption} from './selection';
|
||||
|
||||
const options: SelectionOption[] = [
|
||||
{value: 'vocals', label: 'Vocals'},
|
||||
{value: 'bass', label: 'Bass'},
|
||||
{value: 'other', label: 'Other'},
|
||||
];
|
||||
|
||||
describe('toggleValue', () => {
|
||||
it('adds and removes, keeping option order', () => {
|
||||
expect(toggleValue(options, ['other'], 'vocals')).toEqual(['vocals', 'other']);
|
||||
expect(toggleValue(options, ['vocals', 'other'], 'vocals')).toEqual(['other']);
|
||||
});
|
||||
|
||||
it('refuses a removal that would go below the minimum', () => {
|
||||
expect(toggleValue(options, ['bass'], 'bass', {min: 1})).toEqual(['bass']);
|
||||
expect(toggleValue(options, ['bass', 'other'], 'bass', {min: 1})).toEqual(['other']);
|
||||
});
|
||||
|
||||
it('refuses an addition beyond the maximum', () => {
|
||||
expect(toggleValue(options, ['vocals', 'bass'], 'other', {max: 2})).toEqual(['vocals', 'bass']);
|
||||
});
|
||||
|
||||
it('never changes a disabled option, and ignores unknown values', () => {
|
||||
const withDisabled = [...options, {value: 'drums', label: 'Drums', disabled: true}];
|
||||
expect(toggleValue(withDisabled, ['vocals'], 'drums')).toEqual(['vocals']);
|
||||
expect(toggleValue(withDisabled, ['vocals', 'drums'], 'drums')).toEqual(['vocals', 'drums']);
|
||||
expect(toggleValue(options, ['vocals', 'gone'], 'bass')).toEqual(['vocals', 'bass']);
|
||||
});
|
||||
|
||||
it('does not modify its input', () => {
|
||||
const current = ['vocals'];
|
||||
toggleValue(options, current, 'bass');
|
||||
expect(current).toEqual(['vocals']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectAll', () => {
|
||||
it('selects every enabled option and keeps selected disabled ones', () => {
|
||||
const withDisabled = [...options, {value: 'drums', label: 'Drums', disabled: true}];
|
||||
expect(selectAll(withDisabled, [])).toEqual(['vocals', 'bass', 'other']);
|
||||
expect(selectAll(withDisabled, ['drums'])).toEqual(['vocals', 'bass', 'other', 'drums']);
|
||||
});
|
||||
|
||||
it('keeps the current selection and fills up to the maximum in option order', () => {
|
||||
expect(selectAll(options, ['other'], {max: 2})).toEqual(['vocals', 'other']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summaryText', () => {
|
||||
it('uses the placeholder when nothing is selected', () => {
|
||||
expect(summaryText(options, [], {placeholder: 'Select tracks'})).toEqual({text: 'Select tracks', placeholder: true});
|
||||
});
|
||||
|
||||
it('names one or two selections, counts more, and says all when everything is selected', () => {
|
||||
expect(summaryText(options, ['bass'])).toEqual({text: 'Bass', placeholder: false});
|
||||
expect(summaryText(options, ['vocals', 'bass'])).toEqual({text: 'Vocals, Bass', placeholder: false});
|
||||
const four = [...options, {value: 'keys', label: 'Keys'}];
|
||||
expect(summaryText(four, ['vocals', 'bass', 'keys'], {itemNoun: 'track'}).text).toBe('3 tracks');
|
||||
expect(summaryText(four, ['vocals', 'bass', 'keys']).text).toBe('3 selected');
|
||||
expect(summaryText(options, ['vocals', 'bass', 'other'], {itemNoun: 'track'}).text).toBe('All tracks');
|
||||
expect(summaryText(options, ['vocals', 'bass', 'other']).text).toBe('All');
|
||||
});
|
||||
|
||||
it('does not say all for a single option', () => {
|
||||
expect(summaryText([options[0]], ['vocals'], {itemNoun: 'track'}).text).toBe('Vocals');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocked', () => {
|
||||
it('locks disabled options, the last selections at the minimum, and additions at the maximum', () => {
|
||||
expect(isLocked({value: 'x', label: 'X', disabled: true}, [], {})).toBe(true);
|
||||
expect(isLocked(options[1], ['bass'], {min: 1})).toBe(true);
|
||||
expect(isLocked(options[1], ['bass', 'other'], {min: 1})).toBe(false);
|
||||
expect(isLocked(options[2], ['vocals', 'bass'], {max: 2})).toBe(true);
|
||||
expect(isLocked(options[0], ['vocals', 'bass'], {max: 2})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Selection rules shared by DTMultiSelect and DTSelect: toggling within limits, select all, and the summary line.
|
||||
*
|
||||
* Pure, so it is unit-tested without rendering. The web package keeps an identical copy in
|
||||
* packages/react/src/utils/selection.ts; change both together.
|
||||
*/
|
||||
|
||||
export interface SelectionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectionLimits {
|
||||
/** Fewest selected values a removal may leave. */
|
||||
min?: number;
|
||||
/** Most selected values an addition may reach. */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const inOptionOrder = (options: readonly SelectionOption[], values: ReadonlySet<string>): string[] =>
|
||||
options.filter(option => values.has(option.value)).map(option => option.value);
|
||||
|
||||
/**
|
||||
* Adds or removes `value`. Refuses a change that breaks a limit or touches a disabled option.
|
||||
* The result is in option order; values that are not options are dropped.
|
||||
*/
|
||||
export function toggleValue(
|
||||
options: readonly SelectionOption[],
|
||||
current: readonly string[],
|
||||
value: string,
|
||||
{min = 0, max}: SelectionLimits = {},
|
||||
): string[] {
|
||||
const chosen = new Set(inOptionOrder(options, new Set(current)));
|
||||
const option = options.find(candidate => candidate.value === value);
|
||||
if (!option || option.disabled) return inOptionOrder(options, chosen);
|
||||
if (chosen.has(value)) {
|
||||
if (chosen.size > min) chosen.delete(value);
|
||||
} else if (max === undefined || chosen.size < max) {
|
||||
chosen.add(value);
|
||||
}
|
||||
return inOptionOrder(options, chosen);
|
||||
}
|
||||
|
||||
/** Keeps the current selection and adds every enabled option, in option order, up to `max`. */
|
||||
export function selectAll(
|
||||
options: readonly SelectionOption[],
|
||||
current: readonly string[],
|
||||
{max}: Pick<SelectionLimits, 'max'> = {},
|
||||
): string[] {
|
||||
const chosen = new Set(inOptionOrder(options, new Set(current)));
|
||||
for (const option of options) {
|
||||
if (max !== undefined && chosen.size >= max) break;
|
||||
if (!option.disabled) chosen.add(option.value);
|
||||
}
|
||||
return inOptionOrder(options, chosen);
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary line: the placeholder when nothing is selected, one or two labels, a count beyond that, and "All"
|
||||
* when every option (of more than one) is selected. `itemNoun` is singular ("track" gives "3 tracks").
|
||||
*/
|
||||
export function summaryText(
|
||||
options: readonly SelectionOption[],
|
||||
value: readonly string[],
|
||||
{placeholder = '', itemNoun}: {placeholder?: string; itemNoun?: string} = {},
|
||||
): {text: string; placeholder: boolean} {
|
||||
const chosen = options.filter(option => value.includes(option.value));
|
||||
if (chosen.length === 0) return {text: placeholder, placeholder: true};
|
||||
if (options.length > 1 && chosen.length === options.length) {
|
||||
return {text: itemNoun ? `All ${itemNoun}s` : 'All', placeholder: false};
|
||||
}
|
||||
if (chosen.length <= 2) return {text: chosen.map(option => option.label).join(', '), placeholder: false};
|
||||
return {text: `${chosen.length} ${itemNoun ? `${itemNoun}s` : 'selected'}`, placeholder: false};
|
||||
}
|
||||
|
||||
/** Whether a row cannot be toggled: disabled, the last selections at the minimum, or an addition at the maximum. */
|
||||
export function isLocked(option: SelectionOption, value: readonly string[], {min = 0, max}: SelectionLimits = {}): boolean {
|
||||
if (option.disabled) return true;
|
||||
return value.includes(option.value) ? value.length <= min : max !== undefined && value.length >= max;
|
||||
}
|
||||
@@ -11,5 +11,5 @@
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @dangerousthings/react
|
||||
|
||||
## 2.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- `DTSelect` and `DTMultiSelect`: docked selection panels with an always-visible summary header that expand their rows in place, on both platforms.
|
||||
|
||||
- react-native: `DTMultiSelect` (checkbox rows, limits, select all, summary text from a tested `selection` module), `DTSelect` (single choice on the same frame), `DTSelectPanel` frame built like `DTCard`. `DTButton` now fills its row and keeps string labels on one line, replacing the patch MIDI Commander carried.
|
||||
- react: `DTSelect` and `DTMultiSelect` over native radios and checkboxes, arrow-key navigation, same selection logic.
|
||||
- web: `.dt-select` / `.dt-multiselect` styles for both brands, with `--error` and `--disabled` modifiers.
|
||||
|
||||
## 2.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Still-image setting for motion sensitivity. `stillImages` on both providers (also true at motion 0 and on brands that never animate), a switch in `DTSettingsPanel`, and `DTMotionImage`, which renders a still frame instead of an animated source when the setting is on. React Native adds `useDTStillImages`; the web provider stamps `data-still`.
|
||||
|
||||
## 2.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dangerousthings/react",
|
||||
"version": "2.2.0",
|
||||
"version": "2.4.0",
|
||||
"description": "React web components for the Dangerous Things design system",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -42,13 +42,14 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"@dangerousthings/tokens": "*",
|
||||
"@dangerousthings/web": "*"
|
||||
"@dangerousthings/web": "*",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dangerousthings/tokens": "*",
|
||||
@@ -57,6 +58,7 @@
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"typescript": "^5.8.0"
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* DTMotionImage — an animated image (GIF, animated WebP) that shows a still
|
||||
* frame when the viewer has asked for stills, turned animation off, or is on
|
||||
* a brand that never animates. GIF timing cannot be scaled, so the only
|
||||
* honest treatment of an animated picture for motion-sensitive viewers is
|
||||
* not to play it.
|
||||
*/
|
||||
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
import { useDTWebTheme } from '../hooks/useDTWebTheme';
|
||||
|
||||
interface DTMotionImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
/** The animated source */
|
||||
src: string;
|
||||
/** A single-frame version of the same picture */
|
||||
still: string;
|
||||
}
|
||||
|
||||
export function DTMotionImage({ src, still, ...rest }: DTMotionImageProps) {
|
||||
const { stillImages } = useDTWebTheme();
|
||||
return <img src={stillImages ? still : src} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* DTMultiSelect — a docked panel whose summary header expands its checkbox list in place.
|
||||
*
|
||||
* Part of the layout, not an overlay: the page places it (e.g. at the bottom of a column). Rows are real
|
||||
* checkboxes inside labels, so keyboard and screen-reader behaviour is the browser's.
|
||||
*
|
||||
* CSS reference: forms-dt.css "Select Panel". Selection rules: ../utils/selection.ts (shared with react-native).
|
||||
*/
|
||||
|
||||
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
import { isLocked, selectAll, summaryText } from '../utils/selection';
|
||||
import { toggleValue } from '../utils/selection';
|
||||
|
||||
export interface DTSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Right-aligned detail, e.g. "565 notes". */
|
||||
meta?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DTMultiSelectProps {
|
||||
options: DTSelectOption[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
/** Header label, e.g. "Tracks". */
|
||||
label: string;
|
||||
/** Summary when nothing is selected. */
|
||||
placeholder?: string;
|
||||
/** Singular noun for the summary: "track" gives "3 tracks" and "All tracks". */
|
||||
itemNoun?: string;
|
||||
minSelected?: number;
|
||||
maxSelected?: number;
|
||||
/** ALL in the header while expanded. @default true */
|
||||
showSelectAll?: boolean;
|
||||
/** App actions in the header before ALL. */
|
||||
headerActions?: ReactNode;
|
||||
/** Rows shown before the list scrolls. @default 5.5 */
|
||||
visibleRows?: number;
|
||||
/** Controlled expansion; uncontrolled when omitted. */
|
||||
expanded?: boolean;
|
||||
/** Initial expansion when uncontrolled. @default false */
|
||||
defaultExpanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
/** Appended to the summary, e.g. "977 notes". */
|
||||
summaryDetail?: string;
|
||||
helperText?: string;
|
||||
/** Error message; also switches the accent to the error colour. */
|
||||
error?: string;
|
||||
/** @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function Chevron() {
|
||||
return (
|
||||
<svg className="dt-select-chevron" viewBox="0 0 12 8" aria-hidden="true">
|
||||
<path d="M0 0h12L6 8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DTMultiSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = '',
|
||||
itemNoun,
|
||||
minSelected,
|
||||
maxSelected,
|
||||
showSelectAll = true,
|
||||
headerActions,
|
||||
visibleRows = 5.5,
|
||||
expanded,
|
||||
defaultExpanded = false,
|
||||
onExpandedChange,
|
||||
summaryDetail,
|
||||
helperText,
|
||||
error,
|
||||
variant = 'normal',
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}: DTMultiSelectProps) {
|
||||
const listId = useId();
|
||||
const [expandedState, setExpandedState] = useState(defaultExpanded);
|
||||
const isOpen = expanded ?? expandedState;
|
||||
const limits = { min: minSelected, max: maxSelected };
|
||||
|
||||
const summary = summaryText(options, value, { placeholder, itemNoun });
|
||||
const summaryLine = summary.placeholder || !summaryDetail ? summary.text : `${summary.text} · ${summaryDetail}`;
|
||||
|
||||
const toggleExpanded = () => {
|
||||
if (expanded === undefined) setExpandedState(!isOpen);
|
||||
onExpandedChange?.(!isOpen);
|
||||
};
|
||||
|
||||
const atMinimum = minSelected !== undefined && minSelected > 0 && value.length <= minSelected;
|
||||
const noun = itemNoun ?? 'option';
|
||||
const hint =
|
||||
error ??
|
||||
(isOpen && atMinimum ? `At least ${minSelected} ${minSelected === 1 ? `${noun} stays` : `${noun}s stay`} on.` : helperText);
|
||||
const allSelected = options.every(option => option.disabled || value.includes(option.value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('dt-multiselect', getVariantClass(variant), error && 'error', disabled && 'disabled', className)}
|
||||
style={style}>
|
||||
<div className="dt-select-outline">
|
||||
<div className="dt-select-panel">
|
||||
<div className={cx('dt-select-header', isOpen && 'expanded')}>
|
||||
<button
|
||||
type="button"
|
||||
className="dt-select-toggle"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listId}
|
||||
disabled={disabled}
|
||||
onClick={toggleExpanded}>
|
||||
<span className="dt-select-text">
|
||||
<span className="dt-select-line">
|
||||
<span className="dt-select-label">{label}</span>
|
||||
<span className="dt-select-count">
|
||||
{value.length}/{options.length}
|
||||
</span>
|
||||
</span>
|
||||
<span className={cx('dt-select-summary', summary.placeholder && 'placeholder')} aria-live="polite">
|
||||
{summaryLine}
|
||||
</span>
|
||||
</span>
|
||||
<Chevron />
|
||||
</button>
|
||||
{headerActions || (isOpen && showSelectAll) ? (
|
||||
<div className="dt-select-actions">
|
||||
{headerActions}
|
||||
{isOpen && showSelectAll ? (
|
||||
<button
|
||||
type="button"
|
||||
className="dt-btn"
|
||||
disabled={disabled || allSelected}
|
||||
onClick={() => onChange(selectAll(options, value, { max: maxSelected }))}>
|
||||
ALL
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="dt-select-list" id={listId} role="group" aria-label={label} style={{ maxHeight: visibleRows * 48 }}>
|
||||
{options.map(option => {
|
||||
const checked = value.includes(option.value);
|
||||
const locked = disabled || isLocked(option, value, limits);
|
||||
return (
|
||||
<label key={option.value} className={cx('dt-select-row', checked && 'checked', locked && 'disabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={locked}
|
||||
onChange={() => onChange(toggleValue(options, value, option.value, limits))}
|
||||
/>
|
||||
<span className="dt-select-row-label">{option.label}</span>
|
||||
{option.meta ? <span className="dt-select-row-meta">{option.meta}</span> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hint ? <div className="dt-select-hint">{hint}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* DTSelect — one choice on the same docked panel as DTMultiSelect.
|
||||
*
|
||||
* Rows are real radio buttons inside labels; the chosen row is filled like a DTMenu active item, and picking
|
||||
* collapses the panel.
|
||||
*
|
||||
* CSS reference: forms-dt.css "Select Panel".
|
||||
*/
|
||||
|
||||
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
import { Chevron, type DTSelectOption } from './DTMultiSelect';
|
||||
|
||||
export interface DTSelectProps {
|
||||
options: DTSelectOption[];
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
/** Header label, e.g. "Track". */
|
||||
label: string;
|
||||
/** Summary when nothing is chosen. */
|
||||
placeholder?: string;
|
||||
/** App actions in the header. */
|
||||
headerActions?: ReactNode;
|
||||
/** Rows shown before the list scrolls. @default 5.5 */
|
||||
visibleRows?: number;
|
||||
/** Controlled expansion; uncontrolled when omitted. */
|
||||
expanded?: boolean;
|
||||
/** Initial expansion when uncontrolled. @default false */
|
||||
defaultExpanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
/** Appended to the summary, e.g. "565 notes". */
|
||||
summaryDetail?: string;
|
||||
helperText?: string;
|
||||
/** Error message; also switches the accent to the error colour. */
|
||||
error?: string;
|
||||
/** @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = '',
|
||||
headerActions,
|
||||
visibleRows = 5.5,
|
||||
expanded,
|
||||
defaultExpanded = false,
|
||||
onExpandedChange,
|
||||
summaryDetail,
|
||||
helperText,
|
||||
error,
|
||||
variant = 'normal',
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}: DTSelectProps) {
|
||||
const listId = useId();
|
||||
const groupName = useId();
|
||||
const [expandedState, setExpandedState] = useState(defaultExpanded);
|
||||
const isOpen = expanded ?? expandedState;
|
||||
|
||||
const chosen = options.find(option => option.value === value);
|
||||
const summaryLine = chosen ? (summaryDetail ? `${chosen.label} · ${summaryDetail}` : chosen.label) : placeholder;
|
||||
|
||||
const setExpanded = (next: boolean) => {
|
||||
if (expanded === undefined) setExpandedState(next);
|
||||
onExpandedChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('dt-select', getVariantClass(variant), error && 'error', disabled && 'disabled', className)}
|
||||
style={style}>
|
||||
<div className="dt-select-outline">
|
||||
<div className="dt-select-panel">
|
||||
<div className={cx('dt-select-header', isOpen && 'expanded')}>
|
||||
<button
|
||||
type="button"
|
||||
className="dt-select-toggle"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listId}
|
||||
disabled={disabled}
|
||||
onClick={() => setExpanded(!isOpen)}>
|
||||
<span className="dt-select-text">
|
||||
<span className="dt-select-line">
|
||||
<span className="dt-select-label">{label}</span>
|
||||
</span>
|
||||
<span className={cx('dt-select-summary', !chosen && 'placeholder')} aria-live="polite">
|
||||
{summaryLine}
|
||||
</span>
|
||||
</span>
|
||||
<Chevron />
|
||||
</button>
|
||||
{headerActions ? <div className="dt-select-actions">{headerActions}</div> : null}
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="dt-select-list" id={listId} role="radiogroup" aria-label={label} style={{ maxHeight: visibleRows * 48 }}>
|
||||
{options.map(option => {
|
||||
const isChosen = option.value === value;
|
||||
const locked = disabled || !!option.disabled;
|
||||
return (
|
||||
<label key={option.value} className={cx('dt-select-row', isChosen && 'checked', locked && 'disabled')}>
|
||||
<input
|
||||
type="radio"
|
||||
name={groupName}
|
||||
checked={isChosen}
|
||||
disabled={locked}
|
||||
onChange={() => {
|
||||
onChange(option.value);
|
||||
setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
<span className="dt-select-row-label">{option.label}</span>
|
||||
{option.meta ? <span className="dt-select-row-meta">{option.meta}</span> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ?? helperText ? <div className="dt-select-hint">{error ?? helperText}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,12 +17,16 @@ import {
|
||||
} from '@dangerousthings/tokens';
|
||||
import { DTCard } from './DTCard';
|
||||
import { DTRadioGroup } from './DTRadioGroup';
|
||||
import { DTSwitch } from './DTSwitch';
|
||||
|
||||
export interface DTSettingsPanelProps {
|
||||
brand?: ThemeBrand;
|
||||
onBrandChange?: (brand: ThemeBrand) => void;
|
||||
motionScale?: number;
|
||||
onMotionScaleChange?: (scale: number) => void;
|
||||
/** Show animated images as a single frame. Forced on (and disabled) when motion is off or the brand never animates. */
|
||||
stillImages?: boolean;
|
||||
onStillImagesChange?: (still: boolean) => void;
|
||||
colorVision?: ColorVisionMode;
|
||||
onColorVisionChange?: (mode: ColorVisionMode) => void;
|
||||
className?: string;
|
||||
@@ -55,6 +59,8 @@ export function DTSettingsPanel({
|
||||
onBrandChange,
|
||||
motionScale = 1,
|
||||
onMotionScaleChange,
|
||||
stillImages = false,
|
||||
onStillImagesChange,
|
||||
colorVision = 'none',
|
||||
onColorVisionChange,
|
||||
className,
|
||||
@@ -62,10 +68,11 @@ export function DTSettingsPanel({
|
||||
}: DTSettingsPanelProps) {
|
||||
const id = useId();
|
||||
const animates = brands[brand]?.animations ?? true;
|
||||
const stillForced = !animates || motionScale === 0;
|
||||
|
||||
return (
|
||||
<div className={className} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', ...style }}>
|
||||
{(onBrandChange || onMotionScaleChange) && (
|
||||
{(onBrandChange || onMotionScaleChange || onStillImagesChange) && (
|
||||
<DTCard title="Theme" variant="normal">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem', padding: '1rem' }}>
|
||||
{onBrandChange && (
|
||||
@@ -123,6 +130,21 @@ export function DTSettingsPanel({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onStillImagesChange && (
|
||||
<div>
|
||||
<DTSwitch
|
||||
checked={stillForced || stillImages}
|
||||
disabled={stillForced}
|
||||
onChange={onStillImagesChange}
|
||||
label="Show animated images as stills"
|
||||
/>
|
||||
<p style={{ ...muted, marginTop: '0.75rem' }}>
|
||||
For motion sensitivity. Animated pictures show a single frame instead of playing.
|
||||
{stillForced ? ' Always on while animation is off.' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DTCard>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { createContext, useEffect, type CSSProperties, type ReactNode } from 'react';
|
||||
import type { ColorVisionMode, ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||
import { brands, type ColorVisionMode, type ThemeBrand, type ThemeMode } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
export interface DTWebThemeContextValue {
|
||||
@@ -16,6 +16,8 @@ export interface DTWebThemeContextValue {
|
||||
colorVision: ColorVisionMode;
|
||||
/** Animation speed, 1 = as designed, 0 = off. */
|
||||
motionScale: number;
|
||||
/** Render animated images as stills. Effective value: also true at motion 0 and on brands that never animate. */
|
||||
stillImages: boolean;
|
||||
}
|
||||
|
||||
export const DTWebThemeContext = createContext<DTWebThemeContextValue | null>(null);
|
||||
@@ -29,6 +31,8 @@ interface DTWebThemeProviderProps {
|
||||
colorVision?: ColorVisionMode;
|
||||
/** Animation speed 0..1 @default 1. Classic never animates regardless. */
|
||||
motionScale?: number;
|
||||
/** Show animated images (GIFs) as a single frame @default false. See DTMotionImage. */
|
||||
stillImages?: boolean;
|
||||
/** When true, sets the data attributes on document.documentElement instead of a wrapper div */
|
||||
documentLevel?: boolean;
|
||||
/** Additional className for the wrapper div (ignored when documentLevel is true) */
|
||||
@@ -41,10 +45,13 @@ export function DTWebThemeProvider({
|
||||
theme = 'dark',
|
||||
colorVision = 'none',
|
||||
motionScale = 1,
|
||||
stillImages = false,
|
||||
documentLevel,
|
||||
className,
|
||||
children,
|
||||
}: DTWebThemeProviderProps) {
|
||||
const still = stillImages || motionScale === 0 || !(brands[brand]?.animations ?? true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentLevel) return;
|
||||
const el = document.documentElement;
|
||||
@@ -52,11 +59,12 @@ export function DTWebThemeProvider({
|
||||
el.dataset.theme = theme;
|
||||
el.dataset.cb = colorVision;
|
||||
el.dataset.motion = String(motionScale);
|
||||
el.dataset.still = String(still);
|
||||
el.style.setProperty('--motion-scale', String(motionScale));
|
||||
}, [documentLevel, brand, theme, colorVision, motionScale]);
|
||||
}, [documentLevel, brand, theme, colorVision, motionScale, still]);
|
||||
|
||||
return (
|
||||
<DTWebThemeContext.Provider value={{ brand, theme, colorVision, motionScale }}>
|
||||
<DTWebThemeContext.Provider value={{ brand, theme, colorVision, motionScale, stillImages: still }}>
|
||||
{documentLevel ? (
|
||||
children
|
||||
) : (
|
||||
@@ -65,6 +73,7 @@ export function DTWebThemeProvider({
|
||||
data-theme={theme}
|
||||
data-cb={colorVision}
|
||||
data-motion={motionScale}
|
||||
data-still={String(still)}
|
||||
style={{ '--motion-scale': motionScale } as CSSProperties}
|
||||
className={cx('dt-theme-root', className)}>
|
||||
{children}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DTWebThemeContext, type DTWebThemeContextValue } from '../components/DT
|
||||
export function useDTWebTheme(): DTWebThemeContextValue {
|
||||
const ctx = useContext(DTWebThemeContext);
|
||||
if (!ctx) {
|
||||
return { brand: 'dt', theme: 'dark', colorVision: 'none', motionScale: 1 };
|
||||
return { brand: 'dt', theme: 'dark', colorVision: 'none', motionScale: 1, stillImages: false };
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ export { DTRadioGroup } from './components/DTRadioGroup';
|
||||
export type { DTRadioOption } from './components/DTRadioGroup';
|
||||
export { DTQuantityStepper } from './components/DTQuantityStepper';
|
||||
export { DTSearchInput } from './components/DTSearchInput';
|
||||
export { DTMultiSelect } from './components/DTMultiSelect';
|
||||
export type { DTMultiSelectProps, DTSelectOption } from './components/DTMultiSelect';
|
||||
export { DTSelect } from './components/DTSelect';
|
||||
export type { DTSelectProps } from './components/DTSelect';
|
||||
|
||||
// Components — layout & animation
|
||||
export { DTProgressBar } from './components/DTProgressBar';
|
||||
@@ -64,3 +68,4 @@ export { DTStaggerContainer } from './components/DTStaggerContainer';
|
||||
// Components — settings
|
||||
export { DTSettingsPanel, motionLabel } from './components/DTSettingsPanel';
|
||||
export type { DTSettingsPanelProps } from './components/DTSettingsPanel';
|
||||
export { DTMotionImage } from './components/DTMotionImage';
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {isLocked, selectAll, summaryText, toggleValue, type SelectionOption} from './selection';
|
||||
|
||||
const options: SelectionOption[] = [
|
||||
{value: 'vocals', label: 'Vocals'},
|
||||
{value: 'bass', label: 'Bass'},
|
||||
{value: 'other', label: 'Other'},
|
||||
];
|
||||
|
||||
describe('toggleValue', () => {
|
||||
it('adds and removes, keeping option order', () => {
|
||||
expect(toggleValue(options, ['other'], 'vocals')).toEqual(['vocals', 'other']);
|
||||
expect(toggleValue(options, ['vocals', 'other'], 'vocals')).toEqual(['other']);
|
||||
});
|
||||
|
||||
it('refuses a removal that would go below the minimum', () => {
|
||||
expect(toggleValue(options, ['bass'], 'bass', {min: 1})).toEqual(['bass']);
|
||||
expect(toggleValue(options, ['bass', 'other'], 'bass', {min: 1})).toEqual(['other']);
|
||||
});
|
||||
|
||||
it('refuses an addition beyond the maximum', () => {
|
||||
expect(toggleValue(options, ['vocals', 'bass'], 'other', {max: 2})).toEqual(['vocals', 'bass']);
|
||||
});
|
||||
|
||||
it('never changes a disabled option, and ignores unknown values', () => {
|
||||
const withDisabled = [...options, {value: 'drums', label: 'Drums', disabled: true}];
|
||||
expect(toggleValue(withDisabled, ['vocals'], 'drums')).toEqual(['vocals']);
|
||||
expect(toggleValue(withDisabled, ['vocals', 'drums'], 'drums')).toEqual(['vocals', 'drums']);
|
||||
expect(toggleValue(options, ['vocals', 'gone'], 'bass')).toEqual(['vocals', 'bass']);
|
||||
});
|
||||
|
||||
it('does not modify its input', () => {
|
||||
const current = ['vocals'];
|
||||
toggleValue(options, current, 'bass');
|
||||
expect(current).toEqual(['vocals']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectAll', () => {
|
||||
it('selects every enabled option and keeps selected disabled ones', () => {
|
||||
const withDisabled = [...options, {value: 'drums', label: 'Drums', disabled: true}];
|
||||
expect(selectAll(withDisabled, [])).toEqual(['vocals', 'bass', 'other']);
|
||||
expect(selectAll(withDisabled, ['drums'])).toEqual(['vocals', 'bass', 'other', 'drums']);
|
||||
});
|
||||
|
||||
it('keeps the current selection and fills up to the maximum in option order', () => {
|
||||
expect(selectAll(options, ['other'], {max: 2})).toEqual(['vocals', 'other']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summaryText', () => {
|
||||
it('uses the placeholder when nothing is selected', () => {
|
||||
expect(summaryText(options, [], {placeholder: 'Select tracks'})).toEqual({text: 'Select tracks', placeholder: true});
|
||||
});
|
||||
|
||||
it('names one or two selections, counts more, and says all when everything is selected', () => {
|
||||
expect(summaryText(options, ['bass'])).toEqual({text: 'Bass', placeholder: false});
|
||||
expect(summaryText(options, ['vocals', 'bass'])).toEqual({text: 'Vocals, Bass', placeholder: false});
|
||||
const four = [...options, {value: 'keys', label: 'Keys'}];
|
||||
expect(summaryText(four, ['vocals', 'bass', 'keys'], {itemNoun: 'track'}).text).toBe('3 tracks');
|
||||
expect(summaryText(four, ['vocals', 'bass', 'keys']).text).toBe('3 selected');
|
||||
expect(summaryText(options, ['vocals', 'bass', 'other'], {itemNoun: 'track'}).text).toBe('All tracks');
|
||||
expect(summaryText(options, ['vocals', 'bass', 'other']).text).toBe('All');
|
||||
});
|
||||
|
||||
it('does not say all for a single option', () => {
|
||||
expect(summaryText([options[0]], ['vocals'], {itemNoun: 'track'}).text).toBe('Vocals');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocked', () => {
|
||||
it('locks disabled options, the last selections at the minimum, and additions at the maximum', () => {
|
||||
expect(isLocked({value: 'x', label: 'X', disabled: true}, [], {})).toBe(true);
|
||||
expect(isLocked(options[1], ['bass'], {min: 1})).toBe(true);
|
||||
expect(isLocked(options[1], ['bass', 'other'], {min: 1})).toBe(false);
|
||||
expect(isLocked(options[2], ['vocals', 'bass'], {max: 2})).toBe(true);
|
||||
expect(isLocked(options[0], ['vocals', 'bass'], {max: 2})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Selection rules shared by DTMultiSelect and DTSelect: toggling within limits, select all, and the summary line.
|
||||
*
|
||||
* Pure, so it is unit-tested without rendering. The React Native package keeps an identical copy in
|
||||
* packages/react-native/src/utils/selection.ts; change both together.
|
||||
*/
|
||||
|
||||
export interface SelectionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectionLimits {
|
||||
/** Fewest selected values a removal may leave. */
|
||||
min?: number;
|
||||
/** Most selected values an addition may reach. */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const inOptionOrder = (options: readonly SelectionOption[], values: ReadonlySet<string>): string[] =>
|
||||
options.filter(option => values.has(option.value)).map(option => option.value);
|
||||
|
||||
/**
|
||||
* Adds or removes `value`. Refuses a change that breaks a limit or touches a disabled option.
|
||||
* The result is in option order; values that are not options are dropped.
|
||||
*/
|
||||
export function toggleValue(
|
||||
options: readonly SelectionOption[],
|
||||
current: readonly string[],
|
||||
value: string,
|
||||
{min = 0, max}: SelectionLimits = {},
|
||||
): string[] {
|
||||
const chosen = new Set(inOptionOrder(options, new Set(current)));
|
||||
const option = options.find(candidate => candidate.value === value);
|
||||
if (!option || option.disabled) return inOptionOrder(options, chosen);
|
||||
if (chosen.has(value)) {
|
||||
if (chosen.size > min) chosen.delete(value);
|
||||
} else if (max === undefined || chosen.size < max) {
|
||||
chosen.add(value);
|
||||
}
|
||||
return inOptionOrder(options, chosen);
|
||||
}
|
||||
|
||||
/** Keeps the current selection and adds every enabled option, in option order, up to `max`. */
|
||||
export function selectAll(
|
||||
options: readonly SelectionOption[],
|
||||
current: readonly string[],
|
||||
{max}: Pick<SelectionLimits, 'max'> = {},
|
||||
): string[] {
|
||||
const chosen = new Set(inOptionOrder(options, new Set(current)));
|
||||
for (const option of options) {
|
||||
if (max !== undefined && chosen.size >= max) break;
|
||||
if (!option.disabled) chosen.add(option.value);
|
||||
}
|
||||
return inOptionOrder(options, chosen);
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary line: the placeholder when nothing is selected, one or two labels, a count beyond that, and "All"
|
||||
* when every option (of more than one) is selected. `itemNoun` is singular ("track" gives "3 tracks").
|
||||
*/
|
||||
export function summaryText(
|
||||
options: readonly SelectionOption[],
|
||||
value: readonly string[],
|
||||
{placeholder = '', itemNoun}: {placeholder?: string; itemNoun?: string} = {},
|
||||
): {text: string; placeholder: boolean} {
|
||||
const chosen = options.filter(option => value.includes(option.value));
|
||||
if (chosen.length === 0) return {text: placeholder, placeholder: true};
|
||||
if (options.length > 1 && chosen.length === options.length) {
|
||||
return {text: itemNoun ? `All ${itemNoun}s` : 'All', placeholder: false};
|
||||
}
|
||||
if (chosen.length <= 2) return {text: chosen.map(option => option.label).join(', '), placeholder: false};
|
||||
return {text: `${chosen.length} ${itemNoun ? `${itemNoun}s` : 'selected'}`, placeholder: false};
|
||||
}
|
||||
|
||||
/** Whether a row cannot be toggled: disabled, the last selections at the minimum, or an addition at the maximum. */
|
||||
export function isLocked(option: SelectionOption, value: readonly string[], {min = 0, max}: SelectionLimits = {}): boolean {
|
||||
if (option.disabled) return true;
|
||||
return value.includes(option.value) ? value.length <= min : max !== undefined && value.length >= max;
|
||||
}
|
||||
@@ -5,5 +5,6 @@
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -7,15 +7,26 @@ import {
|
||||
DTProgressBar,
|
||||
DTAccordion,
|
||||
DTQuantityStepper,
|
||||
DTMultiSelect,
|
||||
DTSelect,
|
||||
} from '@dangerousthings/react';
|
||||
import type { DTAccordionSection } from '@dangerousthings/react';
|
||||
import type { DTAccordionSection, DTSelectOption } from '@dangerousthings/react';
|
||||
import { Section, Row, CodeLabel, DemoLabel } from '../components/Section';
|
||||
|
||||
const SELECT_OPTIONS: DTSelectOption[] = [
|
||||
{ value: 'vocals', label: 'Vocals', meta: '565 notes' },
|
||||
{ value: 'bass', label: 'Bass', meta: '412 notes' },
|
||||
{ value: 'other', label: 'Other', meta: '1,208 notes' },
|
||||
{ value: 'drums', label: 'Drums', meta: '980 notes', disabled: true },
|
||||
];
|
||||
|
||||
export function FormsPage() {
|
||||
const [checks, setChecks] = useState({ a: false, b: true, c: true, d: false });
|
||||
const [switches, setSwitches] = useState({ a: false, b: true, c: true, d: false });
|
||||
const [radioValue, setRadioValue] = useState('nfc');
|
||||
const [stepperValue, setStepperValue] = useState(1);
|
||||
const [tracks, setTracks] = useState<string[]>(['vocals', 'bass']);
|
||||
const [track, setTrack] = useState<string | null>('vocals');
|
||||
|
||||
const accordionSections: DTAccordionSection[] = [
|
||||
{ key: 'size', title: 'Size', children: <div style={{ padding: 'var(--space-4)' }}>Small, Medium, Large options available.</div> },
|
||||
|
||||
@@ -7,6 +7,7 @@ const DEFAULTS: Required<HexGridBackgroundProps> = {
|
||||
margin: 0.05,
|
||||
maxHeight: 3,
|
||||
animationInterval: 1500,
|
||||
animationDuration: 1200,
|
||||
cameraSpeed: 0.02,
|
||||
cameraRadius: 8,
|
||||
fov: 40,
|
||||
|
||||
@@ -8,15 +8,29 @@ import {
|
||||
DTRadioGroup,
|
||||
DTRadioOption,
|
||||
DTQuantityStepper,
|
||||
DTMultiSelect,
|
||||
DTSelect,
|
||||
useDTTheme,
|
||||
} from '@dangerousthings/react-native';
|
||||
import type { DTSelectOption } from '@dangerousthings/react-native';
|
||||
import { ScreenContainer } from '../components/ScreenContainer';
|
||||
import { DemoSection } from '../components/DemoSection';
|
||||
import { CodeLabel } from '../components/CodeLabel';
|
||||
|
||||
const SELECT_OPTIONS: DTSelectOption[] = [
|
||||
{ value: 'vocals', label: 'Vocals', meta: '565 notes' },
|
||||
{ value: 'bass', label: 'Bass', meta: '412 notes' },
|
||||
{ value: 'other', label: 'Other', meta: '1,208 notes' },
|
||||
{ value: 'drums', label: 'Drums', meta: '980 notes', disabled: true },
|
||||
];
|
||||
|
||||
export function FormsScreen() {
|
||||
const theme = useDTTheme();
|
||||
|
||||
// Select panel state
|
||||
const [tracks, setTracks] = useState<string[]>(['vocals', 'bass']);
|
||||
const [track, setTrack] = useState<string | null>('vocals');
|
||||
|
||||
// TextInput state
|
||||
const [username, setUsername] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -285,6 +299,70 @@ export function FormsScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</DemoSection>
|
||||
|
||||
{/* DTMultiSelect */}
|
||||
<DemoSection
|
||||
title="DTMultiSelect"
|
||||
variant="normal"
|
||||
description="Docked summary that expands its checkbox list in place. Tap the header to expand; apps dock it below their scrolling content."
|
||||
>
|
||||
<View style={{ gap: 24 }}>
|
||||
<View>
|
||||
<DTMultiSelect
|
||||
label="Tracks"
|
||||
itemNoun="track"
|
||||
placeholder="Select tracks"
|
||||
options={SELECT_OPTIONS}
|
||||
value={tracks}
|
||||
onChange={setTracks}
|
||||
minSelected={1}
|
||||
summaryDetail={`${SELECT_OPTIONS.filter(o => tracks.includes(o.value)).length * 100} notes`}
|
||||
/>
|
||||
<CodeLabel text="minSelected={1} summaryDetail itemNoun" />
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<DTMultiSelect
|
||||
label="Parts"
|
||||
options={SELECT_OPTIONS}
|
||||
value={[]}
|
||||
onChange={() => {}}
|
||||
placeholder="Select parts"
|
||||
error="Choose at least one part to export."
|
||||
/>
|
||||
<CodeLabel text='error="..."' />
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<DTMultiSelect
|
||||
label="Tracks"
|
||||
variant="emphasis"
|
||||
options={SELECT_OPTIONS}
|
||||
value={['vocals']}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
<CodeLabel text='variant="emphasis" disabled' />
|
||||
</View>
|
||||
</View>
|
||||
</DemoSection>
|
||||
|
||||
{/* DTSelect */}
|
||||
<DemoSection
|
||||
title="DTSelect"
|
||||
variant="emphasis"
|
||||
description="One choice on the same panel. The chosen row is filled like a DTMenu active item, and picking collapses it."
|
||||
>
|
||||
<DTSelect
|
||||
label="Track"
|
||||
variant="emphasis"
|
||||
placeholder="Choose a track"
|
||||
options={SELECT_OPTIONS}
|
||||
value={track}
|
||||
onChange={setTrack}
|
||||
/>
|
||||
<CodeLabel text="value / onChange (single choice)" />
|
||||
</DemoSection>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @dangerousthings/web
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- `DTSelect` and `DTMultiSelect`: docked selection panels with an always-visible summary header that expand their rows in place, on both platforms.
|
||||
|
||||
- react-native: `DTMultiSelect` (checkbox rows, limits, select all, summary text from a tested `selection` module), `DTSelect` (single choice on the same frame), `DTSelectPanel` frame built like `DTCard`. `DTButton` now fills its row and keeps string labels on one line, replacing the patch MIDI Commander carried.
|
||||
- react: `DTSelect` and `DTMultiSelect` over native radios and checkboxes, arrow-key navigation, same selection logic.
|
||||
- web: `.dt-select` / `.dt-multiselect` styles for both brands, with `--error` and `--disabled` modifiers.
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- `.badge` (DTChip) and the contained buttons `.btn-primary` / `.btn-danger` / `.btn-secondary` (DTButton `mode="contained"`) now carry their own box: background, text colour, padding, size, weight, uppercase, and on the dt brand enough right padding to clear the bevel cut. The classic brand gets a base badge style and filled `.mode-*` / status variants; it had none, so chips rendered as plain text outside the storefront's Tailwind utilities.
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dangerousthings/web",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Web CSS theme for the Dangerous Things design system — bevels, glows, form styles",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -194,9 +194,56 @@
|
||||
Filled buttons (primary/danger): direct clip-path, solid bg
|
||||
Outline button (secondary): dual-element technique via ::before
|
||||
============================================================================ */
|
||||
/* Contained buttons (DTButton mode="contained"). Like .badge, these only
|
||||
had a clip-path; the box comes from here. */
|
||||
.btn-primary,
|
||||
.btn-danger,
|
||||
.btn-secondary {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--dt-card-color, var(--color-primary));
|
||||
color: var(--color-bg);
|
||||
border: none;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--dt-card-color, var(--color-error));
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-danger:hover {
|
||||
background: var(--dt-card-color-selected, rgba(var(--color-primary-rgb), 0.7));
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
.btn-danger:disabled,
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-brand="classic"] .btn-primary,
|
||||
[data-brand="classic"] .btn-danger,
|
||||
[data-brand="classic"] .btn-secondary {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .btn-secondary {
|
||||
background: var(--color-bg);
|
||||
color: var(--dt-card-color, var(--color-primary));
|
||||
border: 1px solid var(--dt-card-color, var(--color-primary));
|
||||
}
|
||||
|
||||
[data-brand="dt"] .btn-primary,
|
||||
[data-brand="dt"] .btn-danger {
|
||||
border: none;
|
||||
padding-right: calc(0.75rem + var(--bevel-sm) * 0.5);
|
||||
clip-path: polygon(0% 0%,
|
||||
calc(100% - var(--bevel-sm)) 0%,
|
||||
100% var(--bevel-sm),
|
||||
@@ -207,8 +254,10 @@
|
||||
[data-brand="dt"] .btn-secondary {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background: var(--color-primary);
|
||||
background: var(--dt-card-color, var(--color-primary));
|
||||
color: var(--dt-card-color, var(--color-primary));
|
||||
border: none;
|
||||
padding-right: calc(0.75rem + var(--bevel-sm) * 0.5);
|
||||
clip-path: polygon(0% 0%,
|
||||
calc(100% - var(--bevel-sm)) 0%,
|
||||
100% var(--bevel-sm),
|
||||
@@ -242,6 +291,38 @@
|
||||
Source: storefront .label-clipped pattern
|
||||
Dual-element technique: badge bg = border color, ::before = inner fill
|
||||
============================================================================ */
|
||||
/* Base box for a standalone badge/chip (DTChip). The storefront's badges got
|
||||
their box from Tailwind utilities beside the class; the class itself
|
||||
carried none, so a bare .badge rendered as unpadded text with the bevel
|
||||
cutting into it, and classic had no base style at all. */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.35em 1em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-brand="classic"] .badge {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .badge.mode-normal,
|
||||
[data-brand="classic"] .badge.mode-emphasis,
|
||||
[data-brand="classic"] .badge.mode-warning,
|
||||
[data-brand="classic"] .badge.mode-success,
|
||||
[data-brand="classic"] .badge.mode-other {
|
||||
background: var(--dt-card-color);
|
||||
border-color: var(--dt-card-color);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .badge {
|
||||
--badge-border-color: var(--color-border);
|
||||
--badge-fill: var(--color-surface);
|
||||
@@ -259,6 +340,7 @@
|
||||
0% 100%);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding-right: calc(1em + var(--bevel-sm) * 0.5);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .badge::before {
|
||||
@@ -303,18 +385,26 @@
|
||||
|
||||
/* Classic brand colored badges — same solid fill approach */
|
||||
[data-brand="classic"] .badge-success {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .badge-error {
|
||||
background: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .badge-warning {
|
||||
background: var(--color-warning);
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .badge-info {
|
||||
background: var(--color-info);
|
||||
border-color: var(--color-info);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
|
||||
@@ -477,3 +477,267 @@
|
||||
overflow-y: auto;
|
||||
animation: dt-slide-up 0.3s ease-in-out both;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Select Panel — docked summary that expands its list in place
|
||||
Source: DTMultiSelect.tsx / DTSelect.tsx (react-native)
|
||||
Accent per instance through --dt-card-color, as cards and buttons do.
|
||||
============================================================================ */
|
||||
.dt-multiselect,
|
||||
.dt-select {
|
||||
--dt-select-accent: var(--dt-card-color, var(--color-primary));
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dt-multiselect.error,
|
||||
.dt-select.error {
|
||||
--dt-select-accent: var(--color-error);
|
||||
}
|
||||
|
||||
.dt-multiselect.disabled,
|
||||
.dt-select.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The outline is an accent layer under an inset fill: a border would be cut away by the bevel's diagonals. */
|
||||
[data-brand="dt"] :is(.dt-multiselect, .dt-select) .dt-select-outline {
|
||||
background: var(--dt-select-accent);
|
||||
padding: 5px 2px 2px;
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
100% 0,
|
||||
100% calc(100% - 18px),
|
||||
calc(100% - 18px) 100%,
|
||||
18px 100%,
|
||||
0 calc(100% - 18px)
|
||||
);
|
||||
}
|
||||
|
||||
[data-brand="dt"] :is(.dt-multiselect, .dt-select) .dt-select-panel {
|
||||
background: var(--color-surface);
|
||||
padding-bottom: 14px;
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
100% 0,
|
||||
100% calc(100% - 17px),
|
||||
calc(100% - 17px) 100%,
|
||||
17px 100%,
|
||||
0 calc(100% - 17px)
|
||||
);
|
||||
}
|
||||
|
||||
.dt-select-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
|
||||
.dt-select-header.expanded {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--dt-select-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Label, count, summary and chevron are one toggle. */
|
||||
.dt-select-toggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dt-select-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dt-select-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dt-select-label {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dt-select-accent);
|
||||
}
|
||||
|
||||
.dt-select-count {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dt-select-accent);
|
||||
}
|
||||
|
||||
.dt-select-summary {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dt-select-summary.placeholder {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dt-select-chevron {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 8px;
|
||||
fill: var(--dt-select-accent);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dt-select-header.expanded .dt-select-chevron {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.dt-select-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Cutting a row in half signals more below. */
|
||||
.dt-select-list {
|
||||
max-height: calc(48px * 5.5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dt-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--dt-select-accent) 18%, transparent);
|
||||
background: none;
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dt-select-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dt-select-row.checked {
|
||||
background: color-mix(in srgb, var(--dt-select-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Single choice: the chosen row is filled like DTMenu's active item. */
|
||||
.dt-select .dt-select-row.checked {
|
||||
background: color-mix(in srgb, var(--dt-select-accent) 70%, transparent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.dt-select-row.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.dt-select-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dt-select-row-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dt-select-hint {
|
||||
padding: 8px 12px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error .dt-select-hint {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Classic brand: no bevels — radius, a thin border, lighter type, a count pill. */
|
||||
[data-brand="classic"] :is(.dt-multiselect, .dt-select) .dt-select-outline {
|
||||
clip-path: none;
|
||||
background: var(--color-border);
|
||||
padding: 1px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
[data-brand="classic"] :is(.dt-multiselect, .dt-select).error .dt-select-outline {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
[data-brand="classic"] :is(.dt-multiselect, .dt-select) .dt-select-panel {
|
||||
clip-path: none;
|
||||
background: var(--color-surface);
|
||||
border-radius: calc(var(--radius-lg) - 1px);
|
||||
padding-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-select-header.expanded,
|
||||
[data-brand="classic"] .dt-select-row {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-select-row:hover:not(.disabled) {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-select-label {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
[data-brand="classic"] :is(.dt-select-summary, .dt-select-row-label) {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-select-count {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--dt-select-accent) 14%, transparent);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-select-chevron {
|
||||
fill: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-select-header.expanded .dt-select-chevron {
|
||||
fill: var(--dt-select-accent);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint": {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user