Compare commits

...
3 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 4c09a8df44 Permission ticks centred under their headers; tick, radio and switch edge back to --line-ctrl
CI / check (push) Successful in 2m33s
CI / image (push) Canceled after 48s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 15:11:37 +07:00
DenozordecandClaude Sonnet 5.5 d2b05a1037 Forms on ReUI controls: radio-group installed, native checkboxes and radios replaced
Radios (PanelForm) is ReUI's RadioGroup; Check takes rich text; a bare Tick covers
the table-cell ticks and the list-row switch. Settings IPv6 mode, Change Theme and all
Zones modals no longer draw their own native inputs. Matrix and the data-grid's own
selection stay as they are.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 15:06:22 +07:00
DenozordecandClaude Sonnet 5.5 7f60dc9960 Switch knob sat 7px off the edge: no preflight left the browser's button padding on ReUI buttons
button[data-slot] gets padding 0 in the base layer (ReUI size utilities and the
console's own styles still win). The switch's help now lines up under its label by
the switch's 32px, and the dead .toggle input CSS from the native-switch days is gone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 14:55:20 +07:00
25 changed files with 488 additions and 550 deletions

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }
+2 -1
View File
@@ -60,7 +60,8 @@ it goes FIRST so the strip lands above what it indexes and not under it.
/* The matrix's EDITABLE checkbox: 18 px tall, and until now not enlarged. The
read-only one is `ui/Matrix`'s, is disabled and is no target for anything. */
.chkPerm { composes: hitArea from '../../ui/interaction.module.css'; margin: 0 auto; display: block; }
/* The ReUI checkbox carries its own hit area; this only centres it under its header. */
.chkPerm { display: flex; justify-content: center; }
@media (max-width: 960px) {
.withIndex { grid-template-columns: minmax(0, 1fr); }
+22 -21
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '../../ui/Button'
import { Tick } from '../../ui/Tick'
import { Dialog } from '../../ui/Dialog'
import { SectionHeader } from '../../ui/SectionHeader'
import { SectionIndex } from '../../ui/SectionIndex'
@@ -564,31 +565,31 @@ function PermissionsTable({
<tr key={f.name}>
<td className={styles.who}>{f.name}</td>
<td>
<input
type="checkbox"
className={styles.chkPerm}
aria-label={`${section} · ${f.name} · View`}
checked={f.canView}
onChange={(e) => set(f, { canView: e.target.checked })}
/>
<span className={styles.chkPerm}>
<Tick
label={`${section} · ${f.name} · View`}
checked={f.canView}
onChange={(v) => set(f, { canView: v })}
/>
</span>
</td>
<td>
<input
type="checkbox"
className={styles.chkPerm}
aria-label={`${section} · ${f.name} · Modify`}
checked={f.canModify}
onChange={(e) => set(f, { canModify: e.target.checked })}
/>
<span className={styles.chkPerm}>
<Tick
label={`${section} · ${f.name} · Modify`}
checked={f.canModify}
onChange={(v) => set(f, { canModify: v })}
/>
</span>
</td>
<td>
<input
type="checkbox"
className={styles.chkPerm}
aria-label={`${section} · ${f.name} · Delete`}
checked={f.canDelete}
onChange={(e) => set(f, { canDelete: e.target.checked })}
/>
<span className={styles.chkPerm}>
<Tick
label={`${section} · ${f.name} · Delete`}
checked={f.canDelete}
onChange={(v) => set(f, { canDelete: v })}
/>
</span>
</td>
<td className={styles.tdel}>
<Button onClick={() => onChange(rows.filter((x) => x.name !== f.name))}>Remove</Button>
+1 -1
View File
@@ -63,7 +63,7 @@ row's name goes in `aria-label` instead, as the table checkboxes of Zones do.
It takes BOTH classes `Check` gives its own DOM: `check` on the label (the 26 px
target and the input's alignment) and `toggle` for the switch drawing.
*/
.switch { composes: check toggle from '../../ui/Check.module.css'; }
.switch { composes: check from '../../ui/Check.module.css'; }
.name { font-weight: 600; }
/* The URL wraps after its separators (`ui/UrlText`), not at whatever letter meets the
+4 -3
View File
@@ -9,6 +9,7 @@ import { DataTable } from '../../ui/DataTable'
import { RowAction } from '../../ui/Table'
import { Field, Input, Select } from '../../ui/Field'
import { Button } from '../../ui/Button'
import { Tick } from '../../ui/Tick'
import { PermissionButton } from '../../ui/PermissionButton'
import { Confirm } from '../../ui/Confirm'
import { Notifier } from '../../ui/Notifier'
@@ -768,9 +769,9 @@ export function BlockLists({
return (
canToggle(l) && (
<label className={styles.switch}>
<input
type="checkbox"
aria-label={`Enabled ${what}`}
<Tick
toggle
label={`Enabled ${what}`}
checked={l.enabled}
disabled={off}
onChange={() => setLines((all) => all.map((x, j) => (j === i ? toggleLine(x) : x)))}
+2 -2
View File
@@ -25,7 +25,7 @@ describe('Change Theme (index.html:3818-3862)', () => {
open()
const dialog = screen.getByRole('dialog', { name: 'Change Theme' })
const group = within(dialog).getByRole('radiogroup', { name: 'Theme' })
expect(within(group).getAllByRole('radio').map((r) => r.closest('label')?.textContent?.trim())).toEqual([
expect(within(group).getAllByRole('radio').map((r) => r.getAttribute('aria-label'))).toEqual([
'Use System Theme (default)',
'Light Theme',
'Dark Theme',
@@ -64,7 +64,7 @@ describe('Change Theme (index.html:3818-3862)', () => {
if (stored != null) localStorage.setItem('theme', stored)
open()
expect(radio(label)).toBeChecked()
expect(screen.getAllByRole('radio').filter((r) => (r as HTMLInputElement).checked)).toHaveLength(1)
expect(screen.getAllByRole('radio').filter((r) => r.getAttribute('aria-checked') === 'true')).toHaveLength(1)
})
it('a stored amber checks none, since Amber is not offered', () => {
+2 -1
View File
@@ -42,9 +42,10 @@ export function ChangeTheme({
return (
<Dialog open={open} onOpenChange={onOpenChange} title="Change Theme">
<GroupRow modal label="Theme" role="radiogroup">
<GroupRow modal label="Theme">
<Radios
name="rdChangeTheme"
label="Theme"
value={checked}
options={OPTIONS}
onChange={(v) => setChoice(v as OfferedTheme)}
+3 -3
View File
@@ -199,13 +199,13 @@ describe('the Edit Zone File dialog', () => {
draw()
let dialog = await openFromList(user)
await user.click(await within(dialog).findByLabelText('Overwrite SOA Serial'))
expect(within(dialog).getByLabelText('Overwrite SOA Serial')).toHaveProperty('checked', true)
expect(within(dialog).getByLabelText('Overwrite SOA Serial')).toBeChecked()
await user.click(within(dialog).getAllByRole('button', { name: 'Close' }).at(-1)!)
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
dialog = await openFromList(user)
expect(await within(dialog).findByLabelText('Overwrite SOA Serial')).toHaveProperty('checked', false)
expect(await within(dialog).findByLabelText('Overwrite SOA Serial')).not.toBeChecked()
})
it('Save sends the editor as text to zones/import with overwriteZone=true and the checkbox', async () => {
@@ -297,7 +297,7 @@ describe('Import Zone (v15.5)', () => {
const menu = await rowMenu(user, 'primary.test')
await user.click(within(menu).getByRole('menuitem', { name: 'Import Zone' }))
const dialog = await screen.findByRole('dialog', { name: 'Import Zone - primary.test' })
await user.click(within(dialog).getByLabelText('Text Editor', { selector: 'input' }))
await user.click(within(dialog).getByRole('radio', { name: 'Text Editor' }))
await user.type(within(dialog).getByRole('textbox', { name: 'Text Editor' }), 'www 3600 IN A 10.0.0.2')
const listCalls = spy.mock.calls.filter((c) => c[0] === 'zones/list').length
+7 -8
View File
@@ -16,6 +16,7 @@ import {
type Zone,
} from '../../api/zones'
import { Button } from '../../ui/Button'
import { Tick } from '../../ui/Tick'
import { Loading } from '../../ui/Empty'
import { StaleData } from '../StaleData'
import { Field, Input, Select } from '../../ui/Field'
@@ -596,12 +597,11 @@ export function ZoneList({
th: { style: { width: 38 }, className: tbl.checkCell },
header: (
<label>
<input
<Tick
disabled={stale}
type="checkbox"
aria-label="Select all zones"
label="Select all zones"
checked={allChecked}
onChange={(e) => setChecked(e.target.checked ? zones.map((z) => z.name) : [])}
onChange={(v) => setChecked(v ? zones.map((z) => z.name) : [])}
/>
</label>
),
@@ -733,15 +733,14 @@ function zoneCells(p: RowProps) {
return {
check: (
<label>
<input
type="checkbox"
aria-label={`Select ${name}`}
<Tick
label={`Select ${name}`}
/* With stale data there is no selecting: acting in bulk on a list
that no longer reflects the server is how the wrong zone gets
deleted. */
disabled={p.stale}
checked={p.checked}
onChange={(e) => p.onCheck(e.target.checked)}
onChange={p.onCheck}
/>
</label>
),
+2 -4
View File
@@ -52,9 +52,6 @@
.fields { display:flex; flex-direction:column; gap: var(--s-8); }
.inline { composes: inline from '../../ui/PanelForm.module.css'; }
.suffix { composes: trailer from '../../ui/text.module.css'; }
/* The same row as `ui/Check.module.css`, which is where the minimum height
lives: without it, the radios of "Add Zone", "Import" and "Convert" were 19 px. */
.chk { composes: check from '../../ui/Check.module.css'; }
/* The same role as the `.help` of Settings, DHCP and Administration: same colour
and same line height. With `--faint` it read at 4.64:1 and the others at 5.90:1. */
.help { font-size: var(--t-3); color:var(--mute); line-height:1.55; }
@@ -73,7 +70,8 @@
/* The permissions matrix's checkbox is 18 px tall. `.hitArea` enlarges the target
without moving the drawing, just as in Administration's matrix. */
.chkPerm { composes: hitArea from '../../ui/interaction.module.css'; margin: 0 auto; display: block; }
/* The ReUI checkbox carries its own hit area; this only centres it under its header. */
.chkPerm { display: flex; justify-content: center; }
/*
Stale data: the list is still there, and **you can see it is no longer current**.
+3 -3
View File
@@ -319,9 +319,9 @@ describe('modals', () => {
await user.click(await screen.findByRole('menuitem', { name: 'Convert Zone' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByLabelText('Primary Zone')).toHaveProperty('disabled', true)
expect(within(dialog).getByLabelText('Conditional Forwarder Zone')).toHaveProperty('checked', true)
expect(within(dialog).getByLabelText('Catalog Zone')).toHaveProperty('disabled', true)
expect(within(dialog).getByRole('radio', { name: 'Primary Zone' })).toBeDisabled()
expect(within(dialog).getByRole('radio', { name: 'Conditional Forwarder Zone' })).toBeChecked()
expect(within(dialog).getByRole('radio', { name: 'Catalog Zone' })).toBeDisabled()
})
it('\"Import Zone\" alerts if no file is chosen', async () => {
+63 -93
View File
@@ -8,6 +8,8 @@ import {
type ZoneDetails,
} from '../../../api/records'
import { Button } from '../../../ui/Button'
import { Check } from '../../../ui/Check'
import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import {
@@ -277,10 +279,11 @@ export function AddEditRecord(p: AddEditRecordProps) {
{/* "Overwrite" only exists when adding. */}
{!editing && (
<label className={styles.chk}>
<input type="checkbox" checked={f.overwrite} onChange={(e) => set('overwrite', e.target.checked)} />
Overwrite existing records
</label>
<Check
label="Overwrite existing records"
checked={f.overwrite}
onChange={(v) => set('overwrite', v)}
/>
)}
<Field label="Comments">
@@ -370,21 +373,19 @@ function TypeFields({ f, set, setF, apps, classes, onApp, onClass, editing }: Fi
return (
<>
{text(f.type === 'A' ? 'IPv4 Address' : 'IPv6 Address', 'value', { mono: true })}
<label className={styles.chk}>
<input type="checkbox" checked={f.ptr} onChange={(e) => set('ptr', e.target.checked)} />
Add reverse (PTR) record
</label>
<label className={styles.chk}>
{/* Enabled only while the PTR box is ticked (zone.js:543-546); unticking
the PTR does not untick it. */}
<input
type="checkbox"
checked={f.createPtrZone}
disabled={!f.ptr}
onChange={(e) => set('createPtrZone', e.target.checked)}
/>
Create reverse zone if it does not exists
</label>
<Check
label="Add reverse (PTR) record"
checked={f.ptr}
onChange={(v) => set('ptr', v)}
/>
{/* Enabled only while the PTR box is ticked (zone.js:543-546); unticking
the PTR does not untick it. */}
<Check
label="Create reverse zone if it does not exists"
checked={f.createPtrZone}
disabled={!f.ptr}
onChange={(v) => set('createPtrZone', v)}
/>
</>
)
@@ -418,14 +419,11 @@ function TypeFields({ f, set, setF, apps, classes, onApp, onClass, editing }: Fi
{text('Retry', 'soaRetry', { mono: true, short: true })}
{text('Expire', 'soaExpire', { mono: true, short: true })}
{text('Minimum', 'soaMinimum', { mono: true, short: true })}
<label className={styles.chk}>
<input
type="checkbox"
checked={f.soaUseSerialDateScheme}
onChange={(e) => set('soaUseSerialDateScheme', e.target.checked)}
/>
Use Serial Date Scheme
</label>
<Check
label="Use Serial Date Scheme"
checked={f.soaUseSerialDateScheme}
onChange={(v) => set('soaUseSerialDateScheme', v)}
/>
</>
)
@@ -463,14 +461,11 @@ function TypeFields({ f, set, setF, apps, classes, onApp, onClass, editing }: Fi
/>
)}
</Field>
<label className={styles.chk}>
<input
type="checkbox"
checked={f.txtSplitText}
onChange={(e) => set('txtSplitText', e.target.checked)}
/>
Split text into multiple character strings
</label>
<Check
label="Split text into multiple character strings"
checked={f.txtSplitText}
onChange={(v) => set('txtSplitText', v)}
/>
</>
)
@@ -629,22 +624,16 @@ MII...
</Button>
</div>
</div>
<label className={styles.chk}>
<input
type="checkbox"
checked={f.svcbAutoIpv4Hint}
onChange={(e) => set('svcbAutoIpv4Hint', e.target.checked)}
/>
Use Automatic IPv4 Hint
</label>
<label className={styles.chk}>
<input
type="checkbox"
checked={f.svcbAutoIpv6Hint}
onChange={(e) => set('svcbAutoIpv6Hint', e.target.checked)}
/>
Use Automatic IPv6 Hint
</label>
<Check
label="Use Automatic IPv4 Hint"
checked={f.svcbAutoIpv4Hint}
onChange={(v) => set('svcbAutoIpv4Hint', v)}
/>
<Check
label="Use Automatic IPv6 Hint"
checked={f.svcbAutoIpv6Hint}
onChange={(v) => set('svcbAutoIpv6Hint', v)}
/>
</>
)
@@ -670,32 +659,21 @@ MII...
return (
<>
<GroupRow modal label="Protocol">
{FORWARDER_PROTOCOLS.map((x) => (
<label key={x.value} className={styles.chk}>
<input
type="radio"
name="recordForwarderProtocol"
checked={f.forwarderProtocol === x.value}
disabled={f.forwarderThisServer}
onChange={() => set('forwarderProtocol', x.value)}
/>
{x.label}
</label>
))}
<Radios
name="recordForwarderProtocol"
disabled={f.forwarderThisServer}
value={f.forwarderProtocol}
onChange={(v) => set('forwarderProtocol', v as typeof f.forwarderProtocol)}
options={FORWARDER_PROTOCOLS.map((x) => ({ value: x.value, label: x.label }))}
/>
</GroupRow>
{/* `updateAddEditFormForwarderThisServer` (zone.js:5336-5356): ticked, the
forwarder is `this-server`, fixed, on UDP, and the proxy goes away. */}
<label className={styles.chk}>
<input
type="checkbox"
checked={f.forwarderThisServer}
onChange={(e) => {
const on = e.target.checked
setF((prev) => toggleForwarderThisServer(prev, on))
}}
/>
Use &quot;This Server&quot;
</label>
<Check
label={'Use "This Server"'}
checked={f.forwarderThisServer}
onChange={(on) => setF((prev) => toggleForwarderThisServer(prev, on))}
/>
<div className={styles.help}>
When using &quot;This Server&quot;, if a record does not exists in the zone then the request is
forwarded to the DNS Server&apos;s resolver internally. This allows you to override any record for
@@ -719,27 +697,19 @@ MII...
queried before trying for forwarder with high priority value. Forwarders with the same
priority value will be queried concurrently.
</div>
<label className={styles.chk}>
<input
type="checkbox"
checked={f.forwarderDnssecValidation}
onChange={(e) => set('forwarderDnssecValidation', e.target.checked)}
/>
Enable DNSSEC Validation
</label>
<Check
label="Enable DNSSEC Validation"
checked={f.forwarderDnssecValidation}
onChange={(v) => set('forwarderDnssecValidation', v)}
/>
{!f.forwarderThisServer && (
<GroupRow modal label="Network Proxy">
{PROXY_TYPES.map((x) => (
<label key={x.value} className={styles.chk}>
<input
type="radio"
name="recordProxyType"
checked={f.proxyType === x.value}
onChange={() => set('proxyType', x.value)}
/>
{x.label}
</label>
))}
<Radios
name="recordProxyType"
value={f.proxyType}
onChange={(v) => set('proxyType', v as typeof f.proxyType)}
options={PROXY_TYPES.map((x) => ({ value: x.value, label: x.label }))}
/>
<Field label="Proxy Server Address">
{(id) => (
<Input
+84 -115
View File
@@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'react'
import { createZone, listCatalogs } from '../../../api/zones'
import { getTsigKeyNames } from '../../../api/settings'
import { Button } from '../../../ui/Button'
import { Check } from '../../../ui/Check'
import { Dialog } from '../../../ui/Dialog'
import { Radios } from '../../../ui/PanelForm'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import type { Notice } from '../types'
import styles from '../Zones.module.css'
@@ -167,33 +169,30 @@ export function AddZone({
</Field>
<GroupRow modal label="Zone Type">
{ADD_TYPES.map((t) => (
<label key={t.value} className={styles.chk}>
<input
type="radio"
name="addZoneType"
checked={f.type === t.value}
onChange={() => changeType(t.value)}
/>
{/*
`.chk` is `display:flex`, so every child of the label is an item:
without this `span` the link and the brackets split into three boxes
("Secondary ROOT Zone ( RFC 8806 )"), and the link stopped being
inside a sentence, which is what grants it the target-size
exception. Wrapped, it is text again.
*/}
<span>
{t.label}
{t.reference && (
<>
{' ('}
<External href={t.reference.href}>{t.reference.text}</External>
{')'}
</>
)}
</span>
</label>
))}
{/* A link inside an option stays inside its sentence: wrapped in a span, the
link and the brackets are text again and not three boxes, which is what
grants the link the target-size exception. */}
<Radios
name="addZoneType"
value={f.type}
onChange={(v) => changeType(v as AddZoneKind)}
options={ADD_TYPES.map((t) => ({
value: t.value,
name: t.label,
label: (
<span>
{t.label}
{t.reference && (
<>
{' ('}
<External href={t.reference.href}>{t.reference.text}</External>
{')'}
</>
)}
</span>
),
}))}
/>
</GroupRow>
{v.catalog && catalogs.length > 0 && (
@@ -213,14 +212,11 @@ export function AddZone({
{v.initializeForwarderCheckbox && (
<GroupRow modal label="Conditional Forwarder">
<label className={styles.chk}>
<input
type="checkbox"
checked={f.initializeForwarder}
onChange={(e) => set('initializeForwarder', e.target.checked)}
/>
Initialize Forwarder (FWD) Record
</label>
<Check
label="Initialize Forwarder (FWD) Record"
checked={f.initializeForwarder}
onChange={(v) => set('initializeForwarder', v)}
/>
</GroupRow>
)}
@@ -240,14 +236,11 @@ export function AddZone({
{v.soaSerial && (
<GroupRow modal label="Zone Serial">
<label className={styles.chk}>
<input
type="checkbox"
checked={f.useSoaSerialDateScheme}
onChange={(e) => set('useSoaSerialDateScheme', e.target.checked)}
/>
Use SOA Serial Date Scheme
</label>
<Check
label="Use SOA Serial Date Scheme"
checked={f.useSoaSerialDateScheme}
onChange={(v) => set('useSoaSerialDateScheme', v)}
/>
</GroupRow>
)}
@@ -280,17 +273,12 @@ export function AddZone({
{v.transferProtocol && (
<GroupRow modal label="Zone Transfer Protocol">
{TRANSFER_PROTOCOLS.map((p) => (
<label key={p.value} className={styles.chk}>
<input
type="radio"
name="addZoneTransferProtocol"
checked={f.zoneTransferProtocol === p.value}
onChange={() => set('zoneTransferProtocol', p.value)}
/>
{p.label}
</label>
))}
<Radios
name="addZoneTransferProtocol"
value={f.zoneTransferProtocol}
onChange={(v) => set('zoneTransferProtocol', v as typeof f.zoneTransferProtocol)}
options={TRANSFER_PROTOCOLS.map((p) => ({ value: p.value, label: p.label }))}
/>
</GroupRow>
)}
@@ -311,37 +299,30 @@ export function AddZone({
{v.validateZone && (
<GroupRow modal label="Zone Validation">
<label className={styles.chk}>
<input
type="checkbox"
checked={f.validateZone}
onChange={(e) => set('validateZone', e.target.checked)}
/>
Use <External href={RFC_ZONEMD}>ZONEMD</External> to Validate Zone
</label>
<div className={styles.help}>
When enabled, the secondary zone will be validated using the ZONEMD record after every
zone transfer. The zone will get disabled if the validation fails. The zone must be DNSSEC
signed for the validation to work.
</div>
<Check
label="Use ZONEMD to Validate Zone"
text={
<>
Use <External href={RFC_ZONEMD}>ZONEMD</External> to Validate Zone
</>
}
checked={f.validateZone}
onChange={(v) => set('validateZone', v)}
help="When enabled, the secondary zone will be validated using the ZONEMD record after every zone transfer. The zone will get disabled if the validation fails. The zone must be DNSSEC signed for the validation to work."
/>
</GroupRow>
)}
{v.forwarderFields && (
<>
<GroupRow modal label="Protocol">
{FORWARDER_PROTOCOLS.map((p) => (
<label key={p.value} className={styles.chk}>
<input
type="radio"
name="addZoneForwarderProtocol"
disabled={f.useThisServer}
checked={f.forwarderProtocol === p.value}
onChange={() => set('forwarderProtocol', p.value)}
/>
{p.label}
</label>
))}
<Radios
name="addZoneForwarderProtocol"
disabled={f.useThisServer}
value={f.forwarderProtocol}
onChange={(v) => set('forwarderProtocol', v as typeof f.forwarderProtocol)}
options={FORWARDER_PROTOCOLS.map((p) => ({ value: p.value, label: p.label }))}
/>
</GroupRow>
{/* Two help paragraphs: upstream's two, one about "This Server" and
@@ -373,52 +354,40 @@ export function AddZone({
value={f.useThisServer ? 'this-server' : f.forwarder}
onChange={(e) => set('forwarder', e.target.value)}
/>
<label className={styles.chk}>
<input
type="checkbox"
checked={f.useThisServer}
onChange={(e) => {
/* `updateAddZoneFormForwarderThisServer` (zone.js:2979-2998):
ticked, the protocol goes to UDP; unticked, the field is emptied. */
const on = e.target.checked
setF((prev) =>
on
? { ...prev, useThisServer: true, forwarderProtocol: 'Udp' }
: { ...prev, useThisServer: false, forwarder: '' },
)
}}
/>
Use &quot;This Server&quot;
</label>
<Check
label={'Use "This Server"'}
checked={f.useThisServer}
onChange={(on) => {
/* `updateAddZoneFormForwarderThisServer` (zone.js:2979-2998):
ticked, the protocol goes to UDP; unticked, the field is emptied. */
setF((prev) =>
on
? { ...prev, useThisServer: true, forwarderProtocol: 'Udp' }
: { ...prev, useThisServer: false, forwarder: '' },
)
}}
/>
</>
)}
</Row>
<GroupRow modal label="DNSSEC">
<label className={styles.chk}>
<input
type="checkbox"
checked={f.dnssecValidation}
onChange={(e) => set('dnssecValidation', e.target.checked)}
/>
Enable DNSSEC Validation
</label>
<Check
label="Enable DNSSEC Validation"
checked={f.dnssecValidation}
onChange={(v) => set('dnssecValidation', v)}
/>
</GroupRow>
{/* "this-server" takes no proxy: upstream hides the whole block. */}
{!f.useThisServer && (
<GroupRow modal label="Network Proxy">
{PROXY_TYPES.map((p) => (
<label key={p.value} className={styles.chk}>
<input
type="radio"
name="addZoneProxyType"
checked={f.proxyType === p.value}
onChange={() => set('proxyType', p.value)}
/>
{p.label}
</label>
))}
<Radios
name="addZoneProxyType"
value={f.proxyType}
onChange={(v) => set('proxyType', v as typeof f.proxyType)}
options={PROXY_TYPES.map((p) => ({ value: p.value, label: p.label }))}
/>
<Field label="Proxy Server Address">
{(id) => (
<Input
+11 -12
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { convertZone } from '../../../api/zones'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import type { Notice } from '../types'
import styles from '../Zones.module.css'
@@ -111,18 +112,16 @@ export function ConvertZone({
<div className={styles.fields}>
<GroupRow modal label="Convert To">
{(['Primary', 'Forwarder', 'Catalog'] as ConversionTarget[]).map((d) => (
<label key={d} className={styles.chk}>
<input
type="radio"
name="convertTo"
disabled={!table.enabled2.includes(d)}
checked={target === d}
onChange={() => setTarget(d)}
/>
{LABELS[d]}
</label>
))}
<Radios
name="convertTo"
value={target ?? ''}
onChange={(v) => setTarget(v as ConversionTarget)}
options={(['Primary', 'Forwarder', 'Catalog'] as ConversionTarget[]).map((d) => ({
value: d,
label: LABELS[d],
disabled: !table.enabled2.includes(d),
}))}
/>
</GroupRow>
<Alert type="info" title="Note!">
+21 -33
View File
@@ -21,6 +21,7 @@ import {
} from '../../../api/dnssec'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import { Loading } from '../../../ui/Empty'
@@ -535,25 +536,20 @@ export function DnssecProperties({
)}
<div className={frm.mrowCtl}>
{GENERATIONS.map((g) => (
<label key={g.value} className={styles.chk}>
<input
type="radio"
name="addKeyGeneration"
checked={newKey.generation === g.value}
onChange={() =>
// zone.js:501-526: the rollover follows, and the PEM is cleared.
setNewKey((k) => ({
...k,
generation: g.value,
pem: '',
rolloverDays: addKeyRollover(k.keyType, g.value),
}))
}
/>
{g.label}
</label>
))}
<Radios
name="addKeyGeneration"
value={newKey.generation}
onChange={(v) =>
// zone.js:501-526: the rollover follows, and the PEM is cleared.
setNewKey((k) => ({
...k,
generation: v as typeof k.generation,
pem: '',
rolloverDays: addKeyRollover(k.keyType, v as typeof k.generation),
}))
}
options={GENERATIONS.map((g) => ({ value: g.value, label: g.label }))}
/>
</div>
{newKey.algorithm === 'RSA' && newKey.generation === 'Automatic' && (
@@ -635,20 +631,12 @@ MII...
<div className={styles.group}>
<div className={styles.groupTitle}>Proof of Non-Existence</div>
{NX_PROOFS.map((n) => (
<div key={n.value}>
<label className={styles.chk}>
<input
type="radio"
name="propsNxProof"
checked={nxProof === n.value}
onChange={() => setNxProof(n.value as NxProof)}
/>
{n.label}
</label>
<div className={styles.help}>{n.help}</div>
</div>
))}
<Radios
name="propsNxProof"
value={nxProof}
onChange={(v) => setNxProof(v as NxProof)}
options={NX_PROOFS.map((n) => ({ value: n.value, label: n.label, help: n.help }))}
/>
{nxProof === 'NSEC3' && (
<>
+7 -12
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { exportZoneText, saveZoneFile } from '../../../api/zones'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
import { Check } from '../../../ui/Check'
import { Dialog } from '../../../ui/Dialog'
import { Loading } from '../../../ui/Empty'
import { LabeledTextarea } from '../../../ui/Field'
@@ -132,18 +133,12 @@ export function EditZoneFile({
</div>
<div className={frm.mrowCtl}>
<label className={styles.chk}>
<input
type="checkbox"
checked={overwriteSoaSerial}
onChange={(e) => setOverwriteSoaSerial(e.target.checked)}
/>
Overwrite SOA Serial
</label>
<div className={styles.help}>
Enable this option to overwrite existing SOA record serial with the SOA record serial
specified in the zone file editor.
</div>
<Check
label="Overwrite SOA Serial"
checked={overwriteSoaSerial}
onChange={setOverwriteSoaSerial}
help="Enable this option to overwrite existing SOA record serial with the SOA record serial specified in the zone file editor."
/>
</div>
<Alert type="info" title="Note!">
+29 -43
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { importZone } from '../../../api/zones'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
import { Radios } from '../../../ui/PanelForm'
import { Check } from '../../../ui/Check'
import { Dialog } from '../../../ui/Dialog'
import { LabeledTextarea } from '../../../ui/Field'
import type { Notice } from '../types'
@@ -107,52 +109,36 @@ export function ImportZone({
<div className={styles.fields}>
<GroupRow modal label="Import Options">
<label className={styles.chk}>
<input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.target.checked)} />
Overwrite Existing Records
</label>
<div className={styles.help}>
Enable this option to overwrite existing records for the record types being imported.
</div>
<label className={styles.chk}>
<input type="checkbox" checked={overwriteZone} onChange={(e) => setOverwriteZone(e.target.checked)} />
Overwrite Zone
</label>
<div className={styles.help}>
Enable this option to delete all existing records from the zone before importing new records.
</div>
<label className={styles.chk}>
<input
type="checkbox"
checked={overwriteSoaSerial}
onChange={(e) => setOverwriteSoaSerial(e.target.checked)}
/>
Overwrite SOA Serial
</label>
<div className={styles.help}>
Enable this option to overwrite existing SOA record serial with the imported SOA record serial.
</div>
<Check
label="Overwrite Existing Records"
checked={overwrite}
onChange={setOverwrite}
help="Enable this option to overwrite existing records for the record types being imported."
/>
<Check
label="Overwrite Zone"
checked={overwriteZone}
onChange={setOverwriteZone}
help="Enable this option to delete all existing records from the zone before importing new records."
/>
<Check
label="Overwrite SOA Serial"
checked={overwriteSoaSerial}
onChange={setOverwriteSoaSerial}
help="Enable this option to overwrite existing SOA record serial with the imported SOA record serial."
/>
</GroupRow>
<GroupRow modal label="Import Type">
<label className={styles.chk}>
<input
type="radio"
name="importType"
checked={mode === 'File'}
onChange={() => setMode('File')}
/>
Zone File
</label>
<label className={styles.chk}>
<input
type="radio"
name="importType"
checked={mode === 'Text'}
onChange={() => setMode('Text')}
/>
Text Editor
</label>
<Radios
name="importType"
value={mode}
onChange={(v) => setMode(v as typeof mode)}
options={[
{ value: 'File', label: 'Zone File' },
{ value: 'Text', label: 'Text Editor' },
]}
/>
</GroupRow>
{/* Both stay mounted and the other one is hidden, as upstream does
+19 -36
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { signZone, type Algorithm, type NxProof } from '../../../api/dnssec'
import { Button } from '../../../ui/Button'
import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import {
@@ -155,17 +156,12 @@ export function SignZone({
<div className={styles.fields}>
<GroupRow modal label="DNSKEY Algorithm">
{ALGORITHMS.map((a) => (
<label key={a.value} className={styles.chk}>
<input
type="radio"
name="signAlgorithm"
checked={f.algorithm === a.value}
onChange={() => changeAlgorithm(a.value as Algorithm)}
/>
{a.label}
</label>
))}
<Radios
name="signAlgorithm"
value={f.algorithm}
onChange={(v) => changeAlgorithm(v as Algorithm)}
options={ALGORITHMS.map((a) => ({ value: a.value, label: a.label }))}
/>
</GroupRow>
{isRsa ? (
@@ -237,20 +233,12 @@ export function SignZone({
/>
<GroupRow modal label="Proof of Non-Existence">
{NX_PROOFS.map((n) => (
<div key={n.value}>
<label className={styles.chk}>
<input
type="radio"
name="signNxProof"
checked={f.nxProof === n.value}
onChange={() => set('nxProof', n.value as NxProof)}
/>
{n.label}
</label>
<div className={styles.help}>{n.help}</div>
</div>
))}
<Radios
name="signNxProof"
value={f.nxProof}
onChange={(v) => set('nxProof', v as NxProof)}
options={NX_PROOFS.map((n) => ({ value: n.value, label: n.label, help: n.help }))}
/>
</GroupRow>
{f.nxProof === 'NSEC3' && (
@@ -378,17 +366,12 @@ function SigningKey({
return (
<div className={styles.group}>
<div className={styles.groupTitle}>{title}</div>
{GENERATIONS.map((g) => (
<label key={g.value} className={styles.chk}>
<input
type="radio"
name={name}
checked={generation === g.value}
onChange={() => onGeneration(g.value)}
/>
{g.label}
</label>
))}
<Radios
name={name}
value={generation}
onChange={onGeneration}
options={GENERATIONS.map((g) => ({ value: g.value, label: g.label }))}
/>
{/* The size only applies to RSA and only with automatic generation. */}
{isRsa && generation === 'Automatic' && (
+50 -80
View File
@@ -2,6 +2,8 @@ import { useEffect, useState, type ReactNode } from 'react'
import { getZoneOptions, setZoneOptions, type ZoneOptions as Response } from '../../../api/zones'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
import { Check } from '../../../ui/Check'
import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import { Loading } from '../../../ui/Empty'
@@ -188,46 +190,31 @@ export function ZoneOptions({
<div className={styles.help}>Select a Catalog zone to register as its member zone.</div>
{e.overrideQueryAccess && (
<div>
<label className={styles.chk}>
<input
type="checkbox"
disabled={e.overrideLocked}
checked={f.overrideCatalogQueryAccess}
onChange={(ev) => set('overrideCatalogQueryAccess', ev.target.checked)}
/>
Override Query Access Option
</label>
<div className={styles.help}>Enable to override Query Access option in the Catalog zone.</div>
</div>
<Check
label="Override Query Access Option"
disabled={e.overrideLocked}
checked={f.overrideCatalogQueryAccess}
onChange={(v) => set('overrideCatalogQueryAccess', v)}
help="Enable to override Query Access option in the Catalog zone."
/>
)}
{e.overrideZoneTransfer && (
<div>
<label className={styles.chk}>
<input
type="checkbox"
disabled={e.overrideLocked}
checked={f.overrideCatalogZoneTransfer}
onChange={(ev) => set('overrideCatalogZoneTransfer', ev.target.checked)}
/>
Override Zone Transfer Option
</label>
<div className={styles.help}>Enable to override Zone Transfer option in the Catalog zone.</div>
</div>
<Check
label="Override Zone Transfer Option"
disabled={e.overrideLocked}
checked={f.overrideCatalogZoneTransfer}
onChange={(v) => set('overrideCatalogZoneTransfer', v)}
help="Enable to override Zone Transfer option in the Catalog zone."
/>
)}
{e.overrideNotify && (
<div>
<label className={styles.chk}>
<input
type="checkbox"
disabled={e.overrideLocked}
checked={f.overrideCatalogNotify}
onChange={(ev) => set('overrideCatalogNotify', ev.target.checked)}
/>
Override Notify Option
</label>
<div className={styles.help}>Enable to override Notify option in the Catalog zone.</div>
</div>
<Check
label="Override Notify Option"
disabled={e.overrideLocked}
checked={f.overrideCatalogNotify}
onChange={(v) => set('overrideCatalogNotify', v)}
help="Enable to override Notify option in the Catalog zone."
/>
)}
<Alert type="info" title="Note!">
When a zone becomes a member of a Catalog zone, all of the Catalog zone&apos;s Options are
@@ -264,18 +251,13 @@ export function ZoneOptions({
{e.xfrProtocol && (
<GroupRow modal label="Zone Transfer Protocol">
{XFR_PROTOCOLS.map((x) => (
<label key={x.value} className={styles.chk}>
<input
type="radio"
name="zoneOptionsXfr"
disabled={e.primaryServerLocked}
checked={f.primaryZoneTransferProtocol === x.value}
onChange={() => set('primaryZoneTransferProtocol', x.value)}
/>
{x.label}
</label>
))}
<Radios
name="zoneOptionsXfr"
disabled={e.primaryServerLocked}
value={f.primaryZoneTransferProtocol}
onChange={(v) => set('primaryZoneTransferProtocol', v as typeof f.primaryZoneTransferProtocol)}
options={XFR_PROTOCOLS.map((x) => ({ value: x.value, label: x.label }))}
/>
</GroupRow>
)}
@@ -300,23 +282,19 @@ export function ZoneOptions({
)}
{e.validateZone && (
<div>
<label className={styles.chk}>
<input
type="checkbox"
disabled={e.primaryServerLocked}
checked={f.validateZone}
onChange={(ev) => set('validateZone', ev.target.checked)}
/>
Use <External href={RFC_ZONEMD}>ZONEMD</External> to Validate Zone
</label>
{/* index.html:5402 in v15.5.1. */}
<div className={styles.help}>
When enabled, the secondary zone will be validated using the ZONEMD record after every
zone transfer. The zone will get disabled if the validation fails. The zone must be DNSSEC
signed for the validation to work.
</div>
</div>
<Check
label="Use ZONEMD to Validate Zone"
text={
<>
Use <External href={RFC_ZONEMD}>ZONEMD</External> to Validate Zone
</>
}
disabled={e.primaryServerLocked}
checked={f.validateZone}
onChange={(v) => set('validateZone', v)}
/* index.html:5402 in v15.5.1. */
help="When enabled, the secondary zone will be validated using the ZONEMD record after every zone transfer. The zone will get disabled if the validation fails. The zone must be DNSSEC signed for the validation to work."
/>
)}
</>
)}
@@ -616,21 +594,13 @@ function Criterion({
return (
<>
<div className={frm.mrowCtl}>
{options.map((o) => (
<div key={o.value}>
<label className={styles.chk}>
<input
type="radio"
name={name}
disabled={locked}
checked={value === o.value}
onChange={() => onChanged2(o.value)}
/>
{o.label}
</label>
<div className={styles.help}>{o.help}</div>
</div>
))}
<Radios
name={name}
disabled={locked}
value={value}
onChange={onChanged2}
options={options.map((o) => ({ value: o.value, label: o.label, help: o.help }))}
/>
</div>
<Field label={listLabel}>
{(id) => (
+4 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { getZonePermissions, serializePermissions, setZonePermissions } from '../../../api/zones'
import { Button } from '../../../ui/Button'
import { Tick } from '../../../ui/Tick'
import { Dialog } from '../../../ui/Dialog'
import { Field, Select } from '../../../ui/Field'
import { Loading } from '../../../ui/Empty'
@@ -191,13 +192,9 @@ function PermissionsTable({
th: { style: { width: 70 } },
header: ['View', 'Modify', 'Delete'][n],
cell: (f: (typeof rows)[number], i: number) => (
<input
type="checkbox"
className={styles.chkPerm}
aria-label={`${key} for ${f.name}`}
checked={f[key]}
onChange={(e) => change(i, key, e.target.checked)}
/>
<span className={styles.chkPerm}>
<Tick label={`${key} for ${f.name}`} checked={f[key]} onChange={(v) => change(i, key, v)} />
</span>
),
})),
{
+23
View File
@@ -69,6 +69,29 @@ the System / Light / Dark theme without a second palette.
--color-destructive-foreground: var(--dan);
}
/*
No preflight means a `<button>` keeps the browser's own `1px 6px` padding. The ReUI
parts that are buttons with a fixed size (the switch, the checkbox, the dialog's close)
would push their content off the edge by it: the switch's knob sat 7px in from the
left when off. In the base layer, so a ReUI size utility (`px-2.5`) and the console's
own unlayered styles still win over it.
*/
@layer base {
button[data-slot] {
padding: 0;
}
}
/*
A tick, a radio and a switch draw their edge with `--input`, which here is a text
field's border (`--line-field`, a hair above the panel). The console's own box for
these three was `--line-ctrl` (tokens.css: "a checkbox's at rest"), a step stronger,
and that is the one they keep. Unlayered on purpose: it has to outweigh `border-input`.
*/
[data-slot='checkbox'],
[data-slot='radio-group-item'],
[data-slot='switch'] {
--input: var(--line-ctrl);
}
/*
---break---
*/
.dark {
+2 -54
View File
@@ -44,57 +44,5 @@
max-width: 720px;
}
/*
The switch. It is the same `input[type=checkbox]`, redrawn: the track is the
input's own box and the knob is its offset inset shadow, so neither an extra
element nor a pseudo-element the browser paints its own way is needed.
*/
.toggle input {
width: var(--ctrl-track-w);
height: var(--ctrl-track-h);
border-radius: var(--r-full);
background: var(--field);
background-image: none;
box-shadow:
var(--sunken),
inset calc(var(--ctrl-track-w) - var(--ctrl-track-h)) 0 0 -1px transparent;
position: relative;
transition:
background-color var(--t-fast),
border-color var(--t-fast);
}
/* The knob: a circle that travels from one end of the track to the other. */
.toggle input::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: calc(var(--ctrl-track-h) - 6px);
height: calc(var(--ctrl-track-h) - 6px);
border-radius: var(--r-full);
background: var(--mute);
box-shadow: var(--shadow-knob);
transition: transform var(--t-medium), background var(--t-fast);
}
.toggle input:checked {
background-color: var(--acc);
background-image: none;
border-color: var(--acc-ink);
box-shadow: var(--edge-strong);
}
.toggle input:checked::after {
background: var(--on-acc);
transform: translateX(calc(var(--ctrl-track-w) - var(--ctrl-track-h)));
}
.toggle input:active:not(:disabled) { transform: none; }
.toggle .help { margin-left: calc(var(--ctrl-track-w) + var(--s-6)); }
@media (prefers-reduced-motion: reduce) {
.toggle input,
.toggle input::after { transition: none; }
}
/* The switch is ReUI's (32px wide), so its help lines up under the label by that width. */
.helpSwitch { margin-left: calc(32px + var(--s-6)); }
+14 -3
View File
@@ -25,8 +25,12 @@ export function Check({
help,
disabled,
toggle = false,
text,
}: {
/** The control's accessible name, and the visible text unless `text` replaces it. */
label: string
/** Visible text with markup in it (a link), when `label` alone would lose it. */
text?: ReactNode
checked: boolean
onChange: (v: boolean) => void
help?: ReactNode
@@ -52,11 +56,18 @@ export function Check({
) : (
<Checkbox nativeButton render={<button type="button" />} {...common} />
)}
<span className={styles.text} onClick={() => !disabled && onChange(!checked)}>
{label}
<span
className={styles.text}
onClick={(e) => {
// A link inside the text is followed, not a click on the box.
if (disabled || (e.target as HTMLElement).closest('a')) return
onChange(!checked)
}}
>
{text ?? label}
</span>
</div>
{help && <div className={styles.help}>{help}</div>}
{help && <div className={toggle ? `${styles.help} ${styles.helpSwitch}` : styles.help}>{help}</div>}
</div>
)
}
+40 -15
View File
@@ -1,4 +1,5 @@
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Alert } from './Alert'
import { Button } from './Button'
import { Empty } from './Empty'
@@ -242,14 +243,21 @@ export function Trailer({ children }: { children: ReactNode }) {
export interface RadioOption {
value: string
label: string
/** The option's text; with markup in it (a link), give `name` as well. */
label: ReactNode
/** The option's accessible name when `label` is not a string. */
name?: string
help?: ReactNode
disabled?: boolean
}
/*
The radio group. It shares its row and its help with `ui/Check`: they are the
same control with different cardinality, and when each screen wrote its own, a
radio's help and a checkbox's help did not land on the same left edge.
The radio group, ReUI's `RadioGroup`. It shares its row and its help with `ui/Check`:
they are the same control with different cardinality, and when each screen wrote
its own, a radio's help and a checkbox's help did not land on the same left edge.
The item carries its own name and the text beside it only forwards the click, as in
`Check`: a wrapping <label> would name the hidden input as well.
*/
export function Radios({
name,
@@ -257,32 +265,49 @@ export function Radios({
options,
onChange,
disabled,
label,
}: {
name: string
value: string
options: RadioOption[]
onChange: (v: string) => void
disabled?: boolean
/** The group's accessible name, when its heading is not a `GroupRow` label. */
label?: string
}) {
return (
<>
<RadioGroup
name={name}
aria-label={label}
value={value}
disabled={disabled}
onValueChange={(v) => onChange(String(v))}
className="gap-0"
>
{options.map((o) => (
<div key={o.value}>
<label className={check.check}>
<input
type="radio"
name={name}
<div className={check.check}>
<RadioGroupItem
value={o.value}
checked={value === o.value}
disabled={disabled}
onChange={() => onChange(o.value)}
nativeButton
render={<button type="button" />}
disabled={o.disabled}
aria-label={o.name ?? (typeof o.label === 'string' ? o.label : o.value)}
/>
<span className={check.text}>{o.label}</span>
</label>
<span
className={check.text}
onClick={(e) => {
if (disabled || o.disabled || (e.target as HTMLElement).closest('a')) return
onChange(o.value)
}}
>
{o.label}
</span>
</div>
{o.help && <div className={check.help}>{o.help}</div>}
</div>
))}
</>
</RadioGroup>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
/*
The bare control, with no text beside it: a table cell's tick (a row selection, a
permission), or the enable switch of a list row. It is `ui/Check` without the row and
its label, and it is named the same way: through `aria-label`, announced as a
checkbox either way (a switch too), so the tests and the keyboard keep one role.
*/
export function Tick({
label,
checked,
onChange,
disabled,
toggle = false,
indeterminate,
}: {
/** The accessible name: there is no visible text. */
label: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
/** A setting that stays put (a switch), not a selection. */
toggle?: boolean
indeterminate?: boolean
}) {
const common = {
checked,
disabled,
'aria-label': label,
onCheckedChange: (v: boolean) => onChange(v),
}
return toggle ? (
<Switch role="checkbox" nativeButton render={<button type="button" />} {...common} />
) : (
<Checkbox nativeButton render={<button type="button" />} indeterminate={indeterminate} {...common} />
)
}