diff --git a/.github/workflows/draft-pr.yml b/.github/workflows/draft-pr.yml new file mode 100644 index 0000000..e0ea0c6 --- /dev/null +++ b/.github/workflows/draft-pr.yml @@ -0,0 +1,64 @@ +name: Draft PR + +on: + push: + branches: + - feat/* + - fix/* + - refactor/* + - chore/* + - docs/* + +permissions: + contents: read + pull-requests: write + +jobs: + draft-pr: + name: Draft PR + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Generate Scout app token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.SCOUT_APP_ID }} + private-key: ${{ secrets.SCOUT_PRIVATE_KEY }} + permission-contents: read + permission-pull-requests: write + + - name: Create draft PR if not exists + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + BRANCH: ${{ github.ref_name }} + run: | + PLAN_ID=$(echo "$BRANCH" | grep -oP '[a-z]+-\d+' | head -1 || true) + if [ -z "$PLAN_ID" ]; then + echo "Branch '$BRANCH' does not match plan-ID pattern — skipping PR creation" + exit 0 + fi + + TITLE=$(echo "$BRANCH" | sed 's|^[^/]*/||' | sed 's/^[a-z]*-[0-9]*-//' | sed 's/-/ /g' | sed 's/\b\(.\)/\u\1/g') + DISPLAY_ID=$(echo "$PLAN_ID" | sed 's/^\([a-z]*\)/\U\1/' | sed 's/\-[a-z]/\U&/') + FULL_TITLE="${DISPLAY_ID}: ${TITLE}" + + EXISTING=$(gh pr list --head "$BRANCH" --state all --json number --jq '.[0].number' 2>/dev/null || echo "") + + if [ -z "$EXISTING" ]; then + BODY="**Plan:** \`${DISPLAY_ID}\` — see \`papercamp/ideas/${DISPLAY_ID}.md\` for the full idea and its plan." + BODY="${BODY}"$'\n\n'"This PR was auto-created as a draft on the first push to this feature branch." + BODY="${BODY}"$'\n'"Promote to \"Ready for review\" when the plan reaches \`review\` status." + gh pr create \ + --title "$FULL_TITLE" \ + --body "$BODY" \ + --draft \ + --base main + echo "Created draft PR for $FULL_TITLE" + else + echo "PR #$EXISTING already exists for branch '$BRANCH' — skipping" + fi diff --git a/biome.json b/biome.json index 1ee12c6..4f723fc 100644 --- a/biome.json +++ b/biome.json @@ -40,5 +40,17 @@ "formatter": { "trailingCommas": "none" } - } + }, + "overrides": [ + { + "include": ["src/components/select/**", "src/components/modal/**"], + "linter": { + "rules": { + "a11y": { + "useSemanticElements": "off" + } + } + } + } + ] } diff --git a/package.json b/package.json index 3a8dd57..e55295e 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,7 @@ "version": "0.1.1", "description": "Glossy, noisy, cosy React component library — frosted glass, film grain, and lava glow", "type": "module", - "sideEffects": [ - "**/*.css", - "**/*.scss" - ], + "sideEffects": ["**/*.css", "**/*.scss"], "packageManager": "pnpm@10.12.1", "main": "dist/index.cjs", "module": "dist/index.mjs", @@ -96,10 +93,6 @@ "vite-plugin-dts": "^4.5.4" }, "pnpm": { - "onlyBuiltDependencies": [ - "@biomejs/biome", - "@swc/core", - "esbuild" - ] + "onlyBuiltDependencies": ["@biomejs/biome", "@swc/core", "esbuild"] } } diff --git a/papercamp/config.json b/papercamp/config.json index ac5d256..9dc702d 100644 --- a/papercamp/config.json +++ b/papercamp/config.json @@ -4,5 +4,27 @@ "initializedAt": "2026-08-04T14:52:35.357Z", "nextId": { "idea": 9 + }, + "defaultAgents": { + "phase": { + "agent": "claude-code", + "model": "opus", + "effort": "high" + }, + "planDraft": { + "agent": "claude-code", + "model": "opus", + "effort": "high" + }, + "ideaExtend": { + "agent": "claude-code", + "model": "opus", + "effort": "high" + }, + "commitSuggest": { + "agent": "claude-code", + "model": "sonnet", + "effort": "low" + } } } diff --git a/papercamp/ideas/IDEA-2.md b/papercamp/ideas/IDEA-2.md index db73e1c..a90df5d 100644 --- a/papercamp/ideas/IDEA-2.md +++ b/papercamp/ideas/IDEA-2.md @@ -2,7 +2,7 @@ id: IDEA-2 title: Tier 2 — form controls and overlays type: feat -status: planned +status: review created: 2026-08-04 tags: - components @@ -14,10 +14,10 @@ The medium-usage band from the paper-camp inventory: Input (8 files), Modal (6), API conventions from observed usage: Input/Textarea keep DOM-event `onChange` (native-like); Select takes an `options` array and value-based `onChange` (matching how all 7 call sites use it). Modal ships compound parts — `Modal.Body`, `Modal.Footer`, `Modal.Error` — because the identical body/footer/error-line scaffolding is copy-pasted across all six paper-camp modals today. ### Phases -- [ ] Input + Textarea (label, helperText, error, sm/md) -- [ ] Select (options array, controlled value, keyboard nav, hidden native select for forms) -- [ ] Checkbox + Switch -- [ ] Modal (open/onClose/title/size, focus trap, scroll lock, Escape) with Body/Footer/Error compound parts -- [ ] ListItem (active, icon, trailing action slot) -- [ ] Alert (semantic variants, dismissible) + Divider (orientation, label) -- [ ] Spinner + Progress + Skeleton +- [x] Input + Textarea (label, helperText, error, sm/md) +- [x] Select (options array, controlled value, keyboard nav, hidden native select for forms) +- [x] Checkbox + Switch +- [x] Modal (open/onClose/title/size, focus trap, scroll lock, Escape) with Body/Footer/Error compound parts +- [x] ListItem (active, icon, trailing action slot) +- [x] Alert (semantic variants, dismissible) + Divider (orientation, label) +- [x] Spinner + Progress + Skeleton diff --git a/papercamp/ideas/IDEA-4.md b/papercamp/ideas/IDEA-4.md index db5158a..3fd1afb 100644 --- a/papercamp/ideas/IDEA-4.md +++ b/papercamp/ideas/IDEA-4.md @@ -13,8 +13,9 @@ Every component shipped in IDEA-1..3 needs an entry in the showcase's Components ### Phases - [x] Tier 1 entries: IconButton, Stamp, Tooltip, Toast (+ extend Button entry with danger/icons/isActive) - -### Log -- 2026-08-04 — IconButton and Stamp entries live (with variant/size/dot controls, clickable-stamp counter demo); Button entry gained the danger option; Card entry shows sm/md sizes plus a keyboard-accessible clickable card. Tooltip/Toast entries pending their components. - [ ] Tier 2 entries: form controls, Modal, feedback components - [ ] Tier 3 entries: gap-fillers + icon-set grid + +### Log +- 2026-08-04 — IconButton and Stamp entries live (with variant/size/dot controls, clickable-stamp counter demo); Button entry gained the danger option; Card entry shows sm/md sizes plus a keyboard-accessible clickable card. Tooltip/Toast entries now live too. +- 2026-08-04 — Structure fix: an earlier Log insertion accidentally split the Phases list, orphaning the Tier 2/3 phases inside the Log — the idea parsed as one checked phase. Restored all three phases under Phases. diff --git a/src/components/alert/alert.module.scss b/src/components/alert/alert.module.scss new file mode 100644 index 0000000..2b869b5 --- /dev/null +++ b/src/components/alert/alert.module.scss @@ -0,0 +1,89 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.alert { + display: flex; + align-items: flex-start; + gap: $space-2; + padding: $space-3; + background-color: var(--fui-status-neutral-fill); + color: var(--fui-text); + font-family: $font-family-sans; + font-size: $font-size-sm; + line-height: 1.4; + border-left: 4px solid var(--fui-status-neutral-text); +} + +.icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + margin-top: 0.1em; +} + +.message { + flex: 1; + min-width: 0; +} + +.dismiss { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin: -0.25rem -0.25rem -0.25rem 0; + border: none; + padding: 0; + background: transparent; + color: inherit; + font-size: 1.125rem; + line-height: 1; + cursor: pointer; + opacity: 0.55; + + &:hover { + opacity: 1; + } + + &:focus-visible { + @include focus-ring; + } +} + +.info { + background-color: var(--fui-status-info-fill); + border-left-color: var(--fui-status-info-text); + + .icon { + color: var(--fui-status-info-text); + } +} + +.success { + background-color: var(--fui-status-success-fill); + border-left-color: var(--fui-status-success-text); + + .icon { + color: var(--fui-status-success-text); + } +} + +.warning { + background-color: var(--fui-status-warning-fill); + border-left-color: var(--fui-status-warning-text); + + .icon { + color: var(--fui-status-warning-text); + } +} + +.error { + background-color: var(--fui-status-error-fill); + border-left-color: var(--fui-status-error-text); + + .icon { + color: var(--fui-status-error-text); + } +} diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx new file mode 100644 index 0000000..1cc0d85 --- /dev/null +++ b/src/components/alert/alert.tsx @@ -0,0 +1,60 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './alert.module.scss'; + +export type AlertVariant = 'info' | 'success' | 'warning' | 'error'; + +export interface AlertProps extends HTMLAttributes { + variant?: AlertVariant; + dismissible?: boolean; + onDismiss?: () => void; + children: ReactNode; +} + +const icons: Record = { + info: ( + + ), + success: ( + + ), + warning: ( + + ), + error: ( + + ), +}; + +export function Alert({ + variant = 'info', + dismissible = false, + onDismiss, + className, + children, + ...props +}: AlertProps) { + return ( +
+ {icons[variant]} +
{children}
+ {dismissible && ( + + )} +
+ ); +} diff --git a/src/components/alert/index.ts b/src/components/alert/index.ts new file mode 100644 index 0000000..fa1cc01 --- /dev/null +++ b/src/components/alert/index.ts @@ -0,0 +1,2 @@ +export { Alert } from './alert'; +export type { AlertProps, AlertVariant } from './alert'; diff --git a/src/components/checkbox/checkbox.module.scss b/src/components/checkbox/checkbox.module.scss new file mode 100644 index 0000000..de776c7 --- /dev/null +++ b/src/components/checkbox/checkbox.module.scss @@ -0,0 +1,117 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.field { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.control { + display: inline-flex; + align-items: center; + gap: $space-2; + font-family: $font-family-sans; + line-height: 1.4; + color: var(--fui-text); + cursor: pointer; +} + +.disabled { + cursor: not-allowed; +} + +.input { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.box { + position: relative; + flex-shrink: 0; + background-color: var(--fui-surface); + box-shadow: inset 0 0 0 2px var(--fui-shadow-color); + backdrop-filter: var(--fui-nested-backdrop, blur(12px)); + transition: + box-shadow 120ms ease, + background-color 200ms ease; + + &::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + border: solid var(--fui-bg); + border-width: 0 2px 2px 0; + opacity: 0; + transform: translateY(-8%) rotate(45deg); + transition: opacity 120ms ease; + } +} + +.text { + user-select: none; +} + +.input:focus-visible + .box { + box-shadow: inset 0 0 0 2px var(--fui-accent); +} + +.input:checked + .box { + background-color: var(--fui-accent); + box-shadow: inset 0 0 0 2px var(--fui-accent); + + &::after { + opacity: 1; + } +} + +.input:disabled + .box { + opacity: 0.5; +} + +.input:disabled ~ .text { + opacity: 0.5; +} + +.invalid { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); +} + +.md .box { + width: 1.25rem; + height: 1.25rem; + + &::after { + width: 0.375rem; + height: 0.625rem; + } +} + +.sm .box { + width: 1rem; + height: 1rem; + + &::after { + width: 0.3rem; + height: 0.55rem; + } +} + +.message { + margin: 0; + font-size: $font-size-sm; + opacity: 0.7; +} + +.error { + opacity: 1; + color: var(--fui-status-error-text); +} diff --git a/src/components/checkbox/checkbox.tsx b/src/components/checkbox/checkbox.tsx new file mode 100644 index 0000000..599d01c --- /dev/null +++ b/src/components/checkbox/checkbox.tsx @@ -0,0 +1,49 @@ +import { type InputHTMLAttributes, forwardRef, useId } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './checkbox.module.scss'; + +export interface CheckboxProps + extends Omit, 'size' | 'type'> { + label?: string; + helperText?: string; + error?: string; + size?: 'sm' | 'md'; +} + +export const Checkbox = forwardRef(function Checkbox( + { label, helperText, error, size = 'md', id, className, disabled, ...props }, + ref, +) { + const uid = useId(); + const inputId = id ?? `fui-checkbox-${uid}`; + const messageId = `fui-checkbox-message-${uid}`; + const invalid = error != null; + const message = invalid ? error : helperText; + + return ( +
+ + {message != null && ( +

+ {message} +

+ )} +
+ ); +}); diff --git a/src/components/checkbox/index.ts b/src/components/checkbox/index.ts new file mode 100644 index 0000000..edcc3da --- /dev/null +++ b/src/components/checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox } from './checkbox'; +export type { CheckboxProps } from './checkbox'; diff --git a/src/components/divider/divider.module.scss b/src/components/divider/divider.module.scss new file mode 100644 index 0000000..bbc3515 --- /dev/null +++ b/src/components/divider/divider.module.scss @@ -0,0 +1,46 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +$divider-line: color-mix(in srgb, var(--fui-text) 15%, transparent); + +.divider { + box-sizing: border-box; + margin: 0; + border: none; +} + +.horizontal { + width: 100%; + height: 1px; + background-color: $divider-line; +} + +.vertical { + align-self: stretch; + width: 1px; + min-height: 1em; + background-color: $divider-line; +} + +.labelled { + display: flex; + align-items: center; + gap: $space-3; +} + +.line { + flex: 1; + height: 1px; + margin: 0; + border: none; + background-color: $divider-line; +} + +.label { + flex-shrink: 0; + color: var(--fui-status-neutral-text); + font-family: $font-family-sans; + font-size: $font-size-sm; + line-height: 1.2; + white-space: nowrap; +} diff --git a/src/components/divider/divider.tsx b/src/components/divider/divider.tsx new file mode 100644 index 0000000..6e7ac5a --- /dev/null +++ b/src/components/divider/divider.tsx @@ -0,0 +1,28 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './divider.module.scss'; + +export interface DividerProps extends HTMLAttributes { + orientation?: 'horizontal' | 'vertical'; + label?: ReactNode; +} + +export function Divider({ orientation = 'horizontal', label, className, ...props }: DividerProps) { + if (orientation === 'horizontal' && label != null) { + return ( +
+
+ {label} +
+
+ ); + } + + return ( +
+ ); +} diff --git a/src/components/divider/index.ts b/src/components/divider/index.ts new file mode 100644 index 0000000..a2e4d4a --- /dev/null +++ b/src/components/divider/index.ts @@ -0,0 +1,2 @@ +export { Divider } from './divider'; +export type { DividerProps } from './divider'; diff --git a/src/components/input/index.ts b/src/components/input/index.ts new file mode 100644 index 0000000..188cbaa --- /dev/null +++ b/src/components/input/index.ts @@ -0,0 +1,2 @@ +export { Input } from './input'; +export type { InputProps } from './input'; diff --git a/src/components/input/input.module.scss b/src/components/input/input.module.scss new file mode 100644 index 0000000..2ccec9f --- /dev/null +++ b/src/components/input/input.module.scss @@ -0,0 +1,74 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.field { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.label { + font-family: $font-family-mono; + font-size: $font-size-sm; + font-weight: 700; + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.input { + width: 100%; + border: none; + background-color: var(--fui-surface); + color: var(--fui-text); + font-family: $font-family-sans; + line-height: 1.4; + outline: none; + box-shadow: inset 0 0 0 2px var(--fui-shadow-color); + backdrop-filter: var(--fui-nested-backdrop, blur(12px)); + transition: + box-shadow 120ms ease, + background-color 200ms ease; + + &::placeholder { + opacity: 0.5; + } + + &:focus { + box-shadow: inset 0 0 0 2px var(--fui-accent); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.invalid { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); + + &:focus { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); + } +} + +// ─── Sizes ───────────────────────────────────────────────────── +.md { + padding: $space-2 $space-3; + font-size: $font-size-md; +} + +.sm { + padding: $space-1 $space-3; + font-size: $font-size-sm; +} + +.message { + margin: 0; + font-size: $font-size-sm; + opacity: 0.7; +} + +.error { + opacity: 1; + color: var(--fui-status-error-text); +} diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx new file mode 100644 index 0000000..cd736ef --- /dev/null +++ b/src/components/input/input.tsx @@ -0,0 +1,44 @@ +import { type InputHTMLAttributes, forwardRef, useId } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './input.module.scss'; + +export interface InputProps extends Omit, 'size'> { + label?: string; + helperText?: string; + error?: string; + size?: 'sm' | 'md'; +} + +export const Input = forwardRef(function Input( + { label, helperText, error, size = 'md', id, className, ...props }, + ref, +) { + const uid = useId(); + const inputId = id ?? `fui-input-${uid}`; + const messageId = `fui-input-message-${uid}`; + const invalid = error != null; + const message = invalid ? error : helperText; + + return ( +
+ {label && ( + + )} + + {message != null && ( +

+ {message} +

+ )} +
+ ); +}); diff --git a/src/components/list-item/index.ts b/src/components/list-item/index.ts new file mode 100644 index 0000000..0466ceb --- /dev/null +++ b/src/components/list-item/index.ts @@ -0,0 +1,2 @@ +export { ListItem } from './list-item'; +export type { ListItemProps } from './list-item'; diff --git a/src/components/list-item/list-item.module.scss b/src/components/list-item/list-item.module.scss new file mode 100644 index 0000000..5b789e4 --- /dev/null +++ b/src/components/list-item/list-item.module.scss @@ -0,0 +1,70 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.item { + display: flex; + align-items: center; + gap: $space-2; + width: 100%; + border: none; + background: none; + color: var(--fui-text); + font-family: $font-family-sans; + line-height: 1.4; + text-align: left; + cursor: pointer; + outline: none; + transition: + box-shadow 120ms ease, + background-color 200ms ease; + + &:hover:not(:disabled) { + background-color: var(--fui-status-neutral-fill); + } + + &:focus-visible { + box-shadow: inset 0 0 0 2px var(--fui-accent); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.active { + background-color: var(--fui-status-neutral-fill); + box-shadow: inset 3px 0 0 0 var(--fui-accent); + color: var(--fui-accent); + font-weight: 700; +} + +.icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; +} + +.content { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.action { + display: inline-flex; + flex-shrink: 0; + align-items: center; +} + +.md { + padding: $space-2 $space-3; + font-size: $font-size-md; +} + +.sm { + padding: $space-1 $space-3; + font-size: $font-size-sm; +} diff --git a/src/components/list-item/list-item.tsx b/src/components/list-item/list-item.tsx new file mode 100644 index 0000000..16d959b --- /dev/null +++ b/src/components/list-item/list-item.tsx @@ -0,0 +1,29 @@ +import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './list-item.module.scss'; + +export interface ListItemProps extends ButtonHTMLAttributes { + size?: 'sm' | 'md'; + active?: boolean; + icon?: ReactNode; + action?: ReactNode; +} + +export const ListItem = forwardRef(function ListItem( + { size = 'md', active = false, icon, action, className, children, ...props }, + ref, +) { + return ( + + ); +}); diff --git a/src/components/modal/index.ts b/src/components/modal/index.ts new file mode 100644 index 0000000..1d7cc05 --- /dev/null +++ b/src/components/modal/index.ts @@ -0,0 +1,2 @@ +export { Modal } from './modal'; +export type { ModalProps } from './modal'; diff --git a/src/components/modal/modal.module.scss b/src/components/modal/modal.module.scss new file mode 100644 index 0000000..6f65c25 --- /dev/null +++ b/src/components/modal/modal.module.scss @@ -0,0 +1,106 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: $space-4; +} + +.backdrop { + position: absolute; + inset: 0; + border: none; + background-color: rgba(0, 0, 0, 0.6); + cursor: pointer; +} + +.panel { + @include glass; + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: $space-4; + width: 100%; + max-height: calc(100vh - #{$space-8}); + padding: $space-5; + box-shadow: + inset 0 0 0 2px var(--fui-shadow-color), + $glass-shadow; + outline: none; +} + +.sm { + max-width: 24rem; +} + +.md { + max-width: 32rem; +} + +.lg { + max-width: 48rem; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $space-3; +} + +.title { + margin: 0; + font-family: $font-family-title; + font-size: $font-size-xl; + font-weight: 700; +} + +.close { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: $space-6; + height: $space-6; + border: none; + background: none; + color: var(--fui-text); + font-size: $font-size-xl; + line-height: 1; + cursor: pointer; + opacity: 0.6; + transition: opacity 120ms ease; + + &:hover { + opacity: 1; + } + + &:focus-visible { + @include focus-ring; + } +} + +.body { + display: flex; + flex-direction: column; + gap: $space-4; + overflow-y: auto; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: $space-2; +} + +.errorText { + margin: 0; + font-size: $font-size-sm; + color: var(--fui-status-error-text); +} diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx new file mode 100644 index 0000000..c5fbc29 --- /dev/null +++ b/src/components/modal/modal.tsx @@ -0,0 +1,127 @@ +import { type ReactNode, useEffect, useId, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { cn } from '../../utils/style-helpers'; +import styles from './modal.module.scss'; + +const focusableSelector = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export interface ModalProps { + open: boolean; + onClose: () => void; + title?: string; + size?: 'sm' | 'md' | 'lg'; + children: ReactNode; + className?: string; +} + +interface ModalSlotProps { + children: ReactNode; + className?: string; +} + +function ModalRoot({ open, onClose, title, size = 'md', children, className }: ModalProps) { + const uid = useId(); + const titleId = `fui-modal-title-${uid}`; + const panelRef = useRef(null); + const previouslyFocused = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + if (!open) return; + previouslyFocused.current = document.activeElement as HTMLElement | null; + panelRef.current?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== 'Tab') return; + const focusables = panelRef.current?.querySelectorAll(focusableSelector); + if (!focusables || focusables.length === 0) { + event.preventDefault(); + panelRef.current?.focus(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', onKeyDown); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', onKeyDown); + document.body.style.overflow = previousOverflow; + previouslyFocused.current?.focus(); + }; + }, [open]); + + if (!open || typeof document === 'undefined') return null; + + return createPortal( +
+ +
+ {children} + + , + document.body, + ); +} + +function ModalBody({ children, className }: ModalSlotProps) { + return
{children}
; +} + +function ModalFooter({ children, className }: ModalSlotProps) { + return
{children}
; +} + +function ModalError({ children, className }: ModalSlotProps) { + if (!children) return null; + return ( +

+ {children} +

+ ); +} + +export const Modal = Object.assign(ModalRoot, { + Body: ModalBody, + Footer: ModalFooter, + Error: ModalError, +}); diff --git a/src/components/progress/index.ts b/src/components/progress/index.ts new file mode 100644 index 0000000..c5ccce4 --- /dev/null +++ b/src/components/progress/index.ts @@ -0,0 +1,2 @@ +export { Progress } from './progress'; +export type { ProgressProps } from './progress'; diff --git a/src/components/progress/progress.module.scss b/src/components/progress/progress.module.scss new file mode 100644 index 0000000..b565301 --- /dev/null +++ b/src/components/progress/progress.module.scss @@ -0,0 +1,30 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.progress { + appearance: none; + display: block; + width: 100%; + border: none; + background-color: color-mix(in srgb, var(--fui-text) 12%, transparent); + color: var(--fui-progress-fill, var(--fui-accent)); + + &::-webkit-progress-bar { + background-color: transparent; + } + + &::-webkit-progress-value { + background-color: var(--fui-progress-fill, var(--fui-accent)); + transition: width 240ms ease; + } + + &::-moz-progress-bar { + background-color: var(--fui-progress-fill, var(--fui-accent)); + } + + @include reduced-motion { + &::-webkit-progress-value { + transition: none; + } + } +} diff --git a/src/components/progress/progress.tsx b/src/components/progress/progress.tsx new file mode 100644 index 0000000..08c5337 --- /dev/null +++ b/src/components/progress/progress.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties, ProgressHTMLAttributes } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './progress.module.scss'; + +export interface ProgressProps extends Omit, 'color'> { + value: number; + max?: number; + color?: string; + height?: number; +} + +export function Progress({ + value, + max = 100, + color, + height = 8, + className, + style, + ...props +}: ProgressProps) { + const clamped = max > 0 ? Math.max(0, Math.min(value, max)) : 0; + const fill = color ? { '--fui-progress-fill': color } : undefined; + + return ( + + ); +} diff --git a/src/components/select/index.ts b/src/components/select/index.ts new file mode 100644 index 0000000..977a279 --- /dev/null +++ b/src/components/select/index.ts @@ -0,0 +1,2 @@ +export { Select } from './select'; +export type { SelectOption, SelectProps } from './select'; diff --git a/src/components/select/select.module.scss b/src/components/select/select.module.scss new file mode 100644 index 0000000..9fe1d9d --- /dev/null +++ b/src/components/select/select.module.scss @@ -0,0 +1,156 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.field { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.label { + font-family: $font-family-mono; + font-size: $font-size-sm; + font-weight: 700; + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.control { + position: relative; +} + +.trigger { + display: flex; + align-items: center; + gap: $space-2; + width: 100%; + border: none; + background-color: var(--fui-surface); + color: var(--fui-text); + font-family: $font-family-sans; + line-height: 1.4; + text-align: left; + cursor: pointer; + outline: none; + box-shadow: inset 0 0 0 2px var(--fui-shadow-color); + backdrop-filter: var(--fui-nested-backdrop, blur(12px)); + transition: + box-shadow 120ms ease, + background-color 200ms ease; + + &:focus { + box-shadow: inset 0 0 0 2px var(--fui-accent); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.placeholder .value { + opacity: 0.5; +} + +.value { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.arrow { + flex-shrink: 0; + width: 0; + height: 0; + border-right: 4px solid transparent; + border-left: 4px solid transparent; + border-top: 5px solid currentcolor; + opacity: 0.6; +} + +.invalid { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); + + &:focus { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); + } +} + +.md { + padding: $space-2 $space-3; + font-size: $font-size-md; +} + +.sm { + padding: $space-1 $space-3; + font-size: $font-size-sm; +} + +.list { + position: absolute; + z-index: 20; + top: calc(100% + #{$space-1}); + right: 0; + left: 0; + max-height: 16rem; + margin: 0; + padding: $space-1; + overflow-y: auto; + list-style: none; + background-color: var(--fui-surface); + box-shadow: + inset 0 0 0 2px var(--fui-shadow-color), + $glass-shadow; + backdrop-filter: blur(12px); +} + +.option { + display: block; + width: 100%; + padding: $space-2 $space-3; + border: none; + background: none; + color: var(--fui-text); + font-family: $font-family-sans; + font-size: inherit; + text-align: left; + cursor: pointer; + + &[aria-selected='true'] { + font-weight: 700; + color: var(--fui-accent); + } +} + +.active { + background-color: var(--fui-status-neutral-fill); +} + +.optionDisabled { + opacity: 0.4; + cursor: not-allowed; +} + +.native { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.message { + margin: 0; + font-size: $font-size-sm; + opacity: 0.7; +} + +.error { + opacity: 1; + color: var(--fui-status-error-text); +} diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx new file mode 100644 index 0000000..0d2275f --- /dev/null +++ b/src/components/select/select.tsx @@ -0,0 +1,229 @@ +import { type KeyboardEvent, forwardRef, useEffect, useId, useRef, useState } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './select.module.scss'; + +export interface SelectOption { + value: string; + label: string; + disabled?: boolean; +} + +export interface SelectProps { + options: SelectOption[]; + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + label?: string; + helperText?: string; + error?: string; + size?: 'sm' | 'md'; + placeholder?: string; + disabled?: boolean; + name?: string; + required?: boolean; + id?: string; + className?: string; +} + +export const Select = forwardRef(function Select( + { + options, + value, + defaultValue, + onChange, + label, + helperText, + error, + size = 'md', + placeholder = 'Select…', + disabled = false, + name, + required, + id, + className, + }, + ref, +) { + const uid = useId(); + const selectId = id ?? `fui-select-${uid}`; + const listId = `fui-select-list-${uid}`; + const messageId = `fui-select-message-${uid}`; + const optionId = (index: number) => `${selectId}-option-${index}`; + + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(defaultValue ?? ''); + const currentValue = isControlled ? value : internalValue; + + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + + const rootRef = useRef(null); + const triggerRef = useRef(null); + + const invalid = error != null; + const message = invalid ? error : helperText; + const selectedIndex = options.findIndex((o) => o.value === currentValue); + const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined; + + const firstEnabled = () => options.findIndex((o) => !o.disabled); + const lastEnabled = () => { + for (let i = options.length - 1; i >= 0; i--) if (!options[i].disabled) return i; + return -1; + }; + const nextEnabled = (from: number) => { + for (let i = from + 1; i < options.length; i++) if (!options[i].disabled) return i; + return from; + }; + const prevEnabled = (from: number) => { + for (let i = from - 1; i >= 0; i--) if (!options[i].disabled) return i; + return from; + }; + + useEffect(() => { + if (!open) return; + const onPointer = (e: PointerEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + window.addEventListener('pointerdown', onPointer); + return () => window.removeEventListener('pointerdown', onPointer); + }, [open]); + + const openList = () => { + setActiveIndex(selectedIndex >= 0 ? selectedIndex : firstEnabled()); + setOpen(true); + }; + + const commit = (next: string) => { + if (!isControlled) setInternalValue(next); + onChange?.(next); + setOpen(false); + triggerRef.current?.focus(); + }; + + const onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (open) setActiveIndex((i) => nextEnabled(i)); + else openList(); + break; + case 'ArrowUp': + e.preventDefault(); + if (open) setActiveIndex((i) => prevEnabled(i)); + else openList(); + break; + case 'Home': + if (open) { + e.preventDefault(); + setActiveIndex(firstEnabled()); + } + break; + case 'End': + if (open) { + e.preventDefault(); + setActiveIndex(lastEnabled()); + } + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (open && activeIndex >= 0) commit(options[activeIndex].value); + else openList(); + break; + case 'Escape': + if (open) { + e.preventDefault(); + setOpen(false); + } + break; + case 'Tab': + if (open) setOpen(false); + break; + } + }; + + return ( +
+ {label && ( + + )} +
+ + {open && ( +
+ {options.map((option, index) => ( + + ))} +
+ )} +
+ + {message != null && ( +

+ {message} +

+ )} +
+ ); +}); diff --git a/src/components/skeleton/index.ts b/src/components/skeleton/index.ts new file mode 100644 index 0000000..9b1844a --- /dev/null +++ b/src/components/skeleton/index.ts @@ -0,0 +1,2 @@ +export { Skeleton } from './skeleton'; +export type { SkeletonProps } from './skeleton'; diff --git a/src/components/skeleton/skeleton.module.scss b/src/components/skeleton/skeleton.module.scss new file mode 100644 index 0000000..0c2e7f9 --- /dev/null +++ b/src/components/skeleton/skeleton.module.scss @@ -0,0 +1,49 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.skeleton { + position: relative; + display: inline-block; + overflow: hidden; + background-color: color-mix(in srgb, var(--fui-text) 9%, transparent); + + &::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--fui-text) 8%, transparent), + transparent + ); + transform: translateX(-100%); + animation: fui-skeleton-shimmer 1.4s ease-in-out infinite; + } + + @include reduced-motion { + &::after { + animation: none; + } + } +} + +.text { + width: 100%; + height: 0.8em; +} + +.rect { + width: 100%; + height: 100%; +} + +.circle { + border-radius: 999px; +} + +@keyframes fui-skeleton-shimmer { + to { + transform: translateX(100%); + } +} diff --git a/src/components/skeleton/skeleton.tsx b/src/components/skeleton/skeleton.tsx new file mode 100644 index 0000000..1c65c33 --- /dev/null +++ b/src/components/skeleton/skeleton.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './skeleton.module.scss'; + +export interface SkeletonProps extends HTMLAttributes { + variant?: 'text' | 'rect' | 'circle'; + width?: number | string; + height?: number | string; +} + +export function Skeleton({ + variant = 'text', + width, + height, + className, + style, + ...props +}: SkeletonProps) { + return ( +